Q93: disconnect erases the secret here and revokes at the provider where it can
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
06eb2ce8dc
commit
7cd386a38c
15 files changed
+257
-29
No files matched your search
@@ -209,7 +209,17 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
ipcMain.handle(IPC_CHANNELS.deleteMessage, (_event, input) => mutate(() => chat.deleteMessage(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, (_event, input) => mutate(() => providers.revokeCredential(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, async (_event, input) => {
|
||||
try {
|
||||
await providers.revokeCredential(input);
|
||||
notifyDataChanged();
|
||||
return ok(snapshot.load());
|
||||
} catch (error) {
|
||||
// The local disconnect has happened even when the provider refused the revocation.
|
||||
notifyDataChanged();
|
||||
return fail("provider_revoke_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.setProviderPrices, (_event, input) => mutate(() => providers.setPrices(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Credentials no provider uses any more lose their secret; their rows stay for history. */
|
||||
export const ERASE_RETIRED_CREDENTIALS_SQL = `
|
||||
update credentials set ciphertext = x''
|
||||
where id not in (select credential_id from providers where credential_id is not null)
|
||||
and id not in (select credential_id from provider_auth_profiles where active = 1);
|
||||
`;
|
||||
|
||||
export const eraseRetiredCredentialsSchema = `
|
||||
${ERASE_RETIRED_CREDENTIALS_SQL}
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (79, 'erase_retired_credentials', datetime('now'));
|
||||
`;
|
||||
@@ -77,6 +77,7 @@ import { providerCooldownsSchema } from "./migrations/075-provider-cooldowns";
|
||||
import { runPinsSchema } from "./migrations/076-run-pins";
|
||||
import { modelChecksSchema } from "./migrations/077-model-checks";
|
||||
import { requestErrorCodesSchema } from "./migrations/078-request-error-codes";
|
||||
import { eraseRetiredCredentialsSchema } from "./migrations/079-erase-retired-credentials";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -163,6 +164,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 76, name: "run_pins", sql: runPinsSchema },
|
||||
{ version: 77, name: "model_checks", sql: modelChecksSchema },
|
||||
{ version: 78, name: "request_error_codes", sql: requestErrorCodesSchema },
|
||||
{ version: 79, name: "erase_retired_credentials", sql: eraseRetiredCredentialsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface RevokeProviderAuthProfileInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
/** The credential being disconnected; a newer connection made meanwhile is left alone. */
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
export class AuthBrokerService {
|
||||
@@ -61,11 +63,14 @@ export class AuthBrokerService {
|
||||
insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(credentialId, input.orgId, input.providerKey, input.authType, this.vault.encryptSecret(secret), displayHint, now);
|
||||
const replaced = this.db.prepare("select credential_id from provider_auth_profiles where org_id = ? and provider_id = ? and active = 1")
|
||||
.all(input.orgId, input.providerId) as Array<{ credential_id: string }>;
|
||||
this.db.prepare(`
|
||||
update provider_auth_profiles
|
||||
set active = 0
|
||||
where org_id = ? and provider_id = ? and active = 1
|
||||
`).run(input.orgId, input.providerId);
|
||||
for (const row of replaced) this.eraseCredential(row.credential_id);
|
||||
this.db.prepare(`
|
||||
insert into provider_auth_profiles(
|
||||
id, org_id, provider_id, credential_id, provider_key, auth_type,
|
||||
@@ -134,26 +139,36 @@ export class AuthBrokerService {
|
||||
const secret = input.secret.trim();
|
||||
if (!secret) throw new Error("Credential is required.");
|
||||
const displayHint = credentialDisplayHint(secret);
|
||||
// Only a credential still in use may be written: a renewal that lands after a disconnect or replacement must not revive it.
|
||||
const result = this.db.prepare(`
|
||||
update credentials
|
||||
set ciphertext = ?, display_hint = ?
|
||||
where id = ? and org_id = ? and provider_key = ?
|
||||
and id in (select credential_id from provider_auth_profiles where active = 1)
|
||||
`).run(this.vault.encryptSecret(secret), displayHint, input.credentialId, input.orgId, input.providerKey);
|
||||
if (result.changes === 0) throw new Error("Provider credential was not found.");
|
||||
if (result.changes === 0) throw new Error("The sign-in was disconnected or replaced while it was being renewed; the renewed token was discarded.");
|
||||
this.db.prepare("update provider_auth_profiles set display_hint = ? where credential_id = ? and org_id = ?")
|
||||
.run(displayHint, input.credentialId, input.orgId);
|
||||
}
|
||||
|
||||
revokeProviderProfile(input: RevokeProviderAuthProfileInput): void {
|
||||
/** A retired credential keeps its row for history, never its secret. */
|
||||
private eraseCredential(credentialId: string): void {
|
||||
this.db.prepare("update credentials set ciphertext = x'' where id = ?").run(credentialId);
|
||||
}
|
||||
|
||||
/** Retires one credential; false when a newer connection had already replaced it (it is erased either way). */
|
||||
revokeProviderProfile(input: RevokeProviderAuthProfileInput): boolean {
|
||||
this.eraseCredential(input.credentialId);
|
||||
const row = this.db.prepare(`
|
||||
select id, credential_id
|
||||
from provider_auth_profiles
|
||||
where org_id = ?
|
||||
and provider_id = ?
|
||||
and provider_key = ?
|
||||
and credential_id = ?
|
||||
and active = 1
|
||||
`).get(input.orgId, input.providerId, input.providerKey) as { id: string; credential_id: string } | undefined;
|
||||
if (!row) throw new Error("Provider credential was not found.");
|
||||
`).get(input.orgId, input.providerId, input.providerKey, input.credentialId) as { id: string; credential_id: string } | undefined;
|
||||
if (!row) return false;
|
||||
this.db.prepare("update provider_auth_profiles set active = 0 where id = ? and org_id = ?")
|
||||
.run(row.id, input.orgId);
|
||||
this.audit.record({
|
||||
@@ -169,6 +184,7 @@ export class AuthBrokerService {
|
||||
credentialId: row.credential_id,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,16 @@ async function discoverProject(accessToken: string, cancel?: AbortSignal): Promi
|
||||
throw new Error("Antigravity project discovery failed.");
|
||||
}
|
||||
|
||||
/** Revokes the sign-in at Google; revoking the refresh token also ends its access tokens. */
|
||||
export async function revokeAntigravity(refreshToken: string): Promise<void> {
|
||||
const { response, text } = await oauthFetch("https://oauth2.googleapis.com/revoke", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token: refreshToken }),
|
||||
});
|
||||
if (!response.ok) throw httpFailure(`Google revocation failed: ${response.status} ${text}`, response, text);
|
||||
}
|
||||
|
||||
/** Refresh an expired Antigravity access token, retaining the project id. */
|
||||
export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise<OAuthCredentials> {
|
||||
const { response, text } = await oauthFetch(TOKEN_URL, {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch
|
||||
import { KIMI_CODE_BASE_URL } from "./oauth/kimi-code-flow";
|
||||
import { FINISHED_STOP_REASONS, incompleteTurn, requireFinished } from "./provider-tool-turn";
|
||||
import { incompleteReason } from "./provider-tool-turns/responses-format";
|
||||
import { revokeAntigravity } from "./oauth/google-antigravity-flow";
|
||||
import { parseStoredOAuth } from "./oauth/stored-oauth";
|
||||
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14";
|
||||
@@ -222,7 +224,10 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
healthUrl: "https://api.moonshot.cn/v1/models",
|
||||
}),
|
||||
"github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||
"google-antigravity": browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||
"google-antigravity": {
|
||||
...browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||
revoke: async (input) => revokeAntigravity(parseStoredOAuth("google-antigravity", input.secret).refresh),
|
||||
},
|
||||
"gpt-account": browserTokenAdapter("gpt-account", PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION),
|
||||
"claude-pro": {
|
||||
...browserTokenAdapter("claude-pro", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ProviderRefreshResult {
|
||||
|
||||
export interface ProviderRevokeInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
/** The stored credential, for providers that revoke with the refresh token. */
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export type ProviderAuthorizeHandler = (input: ProviderAuthorizeInput) => Promise<ProviderAuthorizeResult>;
|
||||
|
||||
@@ -130,27 +130,40 @@ export class ProviderService {
|
||||
});
|
||||
}
|
||||
|
||||
revokeCredential(input: RevokeProviderCredentialInput): void {
|
||||
/** Revokes at the provider where it can, then always disconnects and erases the credential here; a remote failure is reported, not hidden. */
|
||||
async revokeCredential(input: RevokeProviderCredentialInput): Promise<void> {
|
||||
const provider = this.db.prepare(`
|
||||
select id, provider_key
|
||||
select id, provider_key, auth_type, label, credential_id
|
||||
from providers
|
||||
where id = ? and org_id = ?
|
||||
`).get(input.providerId, input.orgId) as { id: string; provider_key: string } | undefined;
|
||||
`).get(input.providerId, input.orgId) as { id: string; provider_key: string; auth_type: ProviderAuthType; label: string; credential_id: string | null } | undefined;
|
||||
if (!provider) throw new Error("Provider was not found.");
|
||||
this.authBroker.revokeProviderProfile({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
providerKey: provider.provider_key,
|
||||
});
|
||||
this.db.prepare(`
|
||||
update providers
|
||||
set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
|
||||
where id = ? and org_id = ?
|
||||
`).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId);
|
||||
// Sessions mid-turn on this provider stop now instead of finishing on a revoked account.
|
||||
const label = (this.db.prepare("select label from providers where id = ?").get(input.providerId) as { label: string }).label;
|
||||
const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>;
|
||||
for (const run of running) stopRun(run.id, `${label} was disconnected.`);
|
||||
if (!provider.credential_id) throw new Error(`${provider.label} is not connected.`);
|
||||
const credentialId = provider.credential_id;
|
||||
const entry = requireCatalogEntry(provider.provider_key);
|
||||
let remoteError: string | null = null;
|
||||
if (entry.revokesRemotely) {
|
||||
try {
|
||||
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
||||
const secret = this.authBroker.loadProviderSecret({ orgId: input.orgId, providerId: provider.id, providerKey: provider.provider_key, credentialId });
|
||||
await adapter.revoke({ authProfile: adapter.parseAuthProfile(secret), secret });
|
||||
} catch (error) {
|
||||
remoteError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
// The credential read at the start is the one disconnected; a connection made while Google answered stays.
|
||||
const disconnected = this.authBroker.revokeProviderProfile({ orgId: input.orgId, providerId: input.providerId, providerKey: provider.provider_key, credentialId });
|
||||
if (disconnected) {
|
||||
this.db.prepare(`
|
||||
update providers
|
||||
set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
|
||||
where id = ? and org_id = ? and credential_id = ?
|
||||
`).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId, credentialId);
|
||||
// Sessions mid-turn on this provider stop now instead of finishing on a revoked account.
|
||||
const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>;
|
||||
for (const run of running) stopRun(run.id, `${provider.label} was disconnected.`);
|
||||
}
|
||||
const remote = !entry.revokesRemotely ? "not_supported" : remoteError ? "failed" : "revoked";
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider",
|
||||
@@ -158,8 +171,14 @@ export class ProviderService {
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "credential_revoked",
|
||||
payload: { providerKey: provider.provider_key },
|
||||
payload: { providerKey: provider.provider_key, remote, remoteError, replacedMeanwhile: !disconnected },
|
||||
});
|
||||
console.info(`[providers] ${provider.provider_key} disconnected; remote revocation ${remote}`);
|
||||
const problems = [
|
||||
...(disconnected ? [] : [`${provider.label} was reconnected while it was being disconnected; the old sign-in is erased and the new connection stays.`]),
|
||||
...(remoteError ? [`${disconnected ? "Disconnected on this computer, but " : "The old sign-in was not confirmed revoked: "}${provider.label} did not confirm revoking the sign-in (${remoteError}); revoke it in your account.`] : []),
|
||||
];
|
||||
if (problems.length > 0) throw new Error(problems.join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,9 @@ export const zh: Record<string, string> = {
|
||||
"superseded": "已被取代",
|
||||
"stalled": "已停滞",
|
||||
"Resume": "恢复",
|
||||
"Disconnect": "断开连接",
|
||||
"Revokes the sign-in at {provider} and erases it from this computer.": "在 {provider} 撤销登录授权,并从本机删除。",
|
||||
"Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.": "仅从本机删除密钥。在你于 {provider} 账号中撤销之前,该密钥仍然有效。",
|
||||
"API key (if the server asks for one)": "API 密钥(服务需要时填写)",
|
||||
"Checking...": "检查中...",
|
||||
"Check for agent work": "检查能否胜任 Agent 工作",
|
||||
@@ -400,7 +403,6 @@ export const zh: Record<string, string> = {
|
||||
"Restricted": "受限",
|
||||
"Retry": "重试",
|
||||
"Reviews deliverables": "交付物需审核",
|
||||
"Revoke": "撤销",
|
||||
"Risk": "风险",
|
||||
"Role": "角色",
|
||||
"Roster": "名册",
|
||||
|
||||
@@ -72,6 +72,7 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))}
|
||||
onManage={() => setActive(provider)}
|
||||
onRevoke={() => void actions.run(() => window.agentCompany.revokeProviderCredential({ orgId, providerId: provider.id }))}
|
||||
revokesRemotely={requireCatalogEntry(catalog, provider.providerKey).revokesRemotely}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -317,6 +318,7 @@ function ProviderRow({
|
||||
onCheck,
|
||||
onManage,
|
||||
onRevoke,
|
||||
revokesRemotely,
|
||||
}: {
|
||||
provider: ProviderRecord;
|
||||
authProfile: ProviderAuthProfileRecord | null;
|
||||
@@ -324,6 +326,7 @@ function ProviderRow({
|
||||
onCheck: () => void;
|
||||
onManage: () => void;
|
||||
onRevoke: () => void;
|
||||
revokesRemotely: boolean;
|
||||
}) {
|
||||
const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length;
|
||||
return (
|
||||
@@ -353,7 +356,15 @@ function ProviderRow({
|
||||
<span className="reveal row" style={{ gap: 6 }}>
|
||||
<Button onClick={onCheck}>{t("Check")}</Button>
|
||||
<Button onClick={onManage}>{t("Manage")}</Button>
|
||||
<Button tone="ghost" onClick={onRevoke}>{t("Revoke")}</Button>
|
||||
<Button
|
||||
tone="ghost"
|
||||
onClick={onRevoke}
|
||||
title={revokesRemotely
|
||||
? t("Revokes the sign-in at {provider} and erases it from this computer.", { provider: provider.label })
|
||||
: t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}
|
||||
>
|
||||
{t("Disconnect")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -408,3 +419,9 @@ function requireProviderRateLimit(
|
||||
if (!rateLimit) throw new Error(`Provider rate limit state missing for ${provider.id}.`);
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
function requireCatalogEntry(catalog: ProviderCatalogEntry[], providerKey: string): ProviderCatalogEntry {
|
||||
const entry = catalog.find((candidate) => candidate.key === providerKey);
|
||||
if (!entry) throw new Error(`Provider ${providerKey} is not in the catalog.`);
|
||||
return entry;
|
||||
}
|
||||
@@ -324,6 +324,8 @@ export interface ProviderCatalogEntry {
|
||||
/** Set for user-configured OpenAI/Anthropic-compatible endpoints: the UI
|
||||
* collects a base URL + API key + model instead of a fixed provider form. */
|
||||
customEndpoint?: "openai" | "anthropic";
|
||||
/** True when disconnecting also revokes the sign-in at the provider; otherwise it only erases it from this computer. */
|
||||
revokesRemotely: boolean;
|
||||
}
|
||||
|
||||
export interface SkillDefinitionRecord {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PROVIDER_AUTH_TYPE, type ProviderCatalogEntry } from "./domain";
|
||||
import { listProviderModels } from "./provider-models";
|
||||
|
||||
const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels">[] = [
|
||||
const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels" | "revokesRemotely">[] = [
|
||||
{
|
||||
key: "openai",
|
||||
label: "OpenAI",
|
||||
@@ -192,8 +192,12 @@ const CURATED_MODEL_PROVIDERS = new Set([
|
||||
"custom-anthropic",
|
||||
]);
|
||||
|
||||
// Google documents a token revocation endpoint (RFC 7009); the others offer none for what we hold.
|
||||
const REMOTE_REVOCATION = new Set(["google-antigravity"]);
|
||||
|
||||
export const PROVIDER_CATALOG: ProviderCatalogEntry[] = RAW_CATALOG.map((entry) => ({
|
||||
...entry,
|
||||
models: listProviderModels(entry.key),
|
||||
supportsLiveModels: !CURATED_MODEL_PROVIDERS.has(entry.key),
|
||||
revokesRemotely: REMOTE_REVOCATION.has(entry.key),
|
||||
}));
|
||||
@@ -134,7 +134,8 @@ describe("provider contracts", () => {
|
||||
const secret = PROVIDER_SECRET_SAMPLES[provider.key];
|
||||
const profile = adapter.parseAuthProfile(secret);
|
||||
await expect(adapter.authorize({ secret })).resolves.toEqual({ authProfile: profile });
|
||||
await expect(adapter.revoke({ authProfile: profile })).resolves.toBeUndefined();
|
||||
// Remote revocation makes a network call; provider-disconnect.test covers it.
|
||||
if (!provider.revokesRemotely) await expect(adapter.revoke({ authProfile: profile, secret })).resolves.toBeUndefined();
|
||||
await expect(adapter.refresh({ authProfile: profile })).rejects.toThrow("does not support token refresh");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog";
|
||||
import { ERASE_RETIRED_CREDENTIALS_SQL } from "../../src/main/database/migrations/079-erase-retired-credentials";
|
||||
import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
|
||||
import { IntegrationKeyStore } from "../../src/main/services/integration-key-store";
|
||||
import { loadConfiguredProvider } from "../../src/main/services/provider-internal";
|
||||
import { resolveTurnCredential } from "../../src/main/services/provider-tool-turns/credential-resolver";
|
||||
|
||||
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function company(providerKey: string, secret: string, model: string) {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-disconnect-${randomUUID()}.db`));
|
||||
const providers = new ProviderService(connection.db, providerAdapters, vault);
|
||||
let orgId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = ?").get(orgId, providerKey) as { id: string }).id;
|
||||
providers.saveCredential({ orgId, providerId, secret, model });
|
||||
});
|
||||
const ciphertexts = () => (connection.db.prepare("select length(ciphertext) as bytes from credentials order by created_at, rowid").all() as Array<{ bytes: number }>).map((row) => row.bytes);
|
||||
const lastRevocation = () => JSON.parse((connection.db.prepare("select payload_json from audit_events where action = 'credential_revoked' order by rowid desc").get() as { payload_json: string }).payload_json) as Record<string, unknown>;
|
||||
return { connection, providers, orgId, providerId, ciphertexts, lastRevocation };
|
||||
}
|
||||
|
||||
describe("disconnecting a provider", () => {
|
||||
test("only Google Antigravity's sign-in can be revoked at the provider; the catalog says so", () => {
|
||||
expect(PROVIDER_CATALOG.filter((entry) => entry.revokesRemotely).map((entry) => entry.key)).toEqual(["google-antigravity"]);
|
||||
});
|
||||
|
||||
test("an API key is erased from this computer on disconnect, and a replaced key is erased too", async () => {
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("no network call expected"); });
|
||||
const h = company("openai", "sk-first", "gpt-5.4");
|
||||
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: "sk-second", model: "gpt-5.4" }));
|
||||
expect(h.ciphertexts()).toEqual([0, 9]);
|
||||
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
expect(h.ciphertexts()).toEqual([0, 0]);
|
||||
expect(h.lastRevocation()).toMatchObject({ providerKey: "openai", remote: "not_supported" });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a Google sign-in is revoked at Google before it is erased here", async () => {
|
||||
const revoked: string[] = [];
|
||||
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
|
||||
revoked.push(`${url} ${String(init.body)}`);
|
||||
return new Response("", { status: 200 });
|
||||
});
|
||||
const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro");
|
||||
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
expect(revoked).toEqual(["https://oauth2.googleapis.com/revoke token=1%2F%2Frefresh"]);
|
||||
expect(h.ciphertexts()).toEqual([0]);
|
||||
expect(h.lastRevocation()).toMatchObject({ remote: "revoked" });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("when Google does not confirm, the sign-in is still disconnected and erased here, and the failure is said plainly", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } }));
|
||||
const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro");
|
||||
await expect(h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }))
|
||||
.rejects.toThrow("Disconnected on this computer, but Google Antigravity did not confirm revoking the sign-in");
|
||||
expect(h.connection.db.prepare("select enabled, credential_id from providers where id = ?").get(h.providerId)).toEqual({ enabled: 0, credential_id: null });
|
||||
expect(h.ciphertexts()).toEqual([0]);
|
||||
expect(h.lastRevocation()).toMatchObject({ remote: "failed", remoteError: expect.stringContaining("400") });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a renewal that lands after the disconnect is discarded, not written back", async () => {
|
||||
let answer: (response: Response) => void = () => {};
|
||||
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
|
||||
const h = company("claude-pro", JSON.stringify({ access: "a", refresh: "r", expires: 0 }), "claude-sonnet-4-5");
|
||||
const renewal = resolveTurnCredential({ authBroker: new AuthBrokerService(h.connection.db, vault), integrationKeys: new IntegrationKeyStore(h.connection.db, vault), orgId: h.orgId, provider: loadConfiguredProvider(h.connection.db, h.orgId, h.providerId) });
|
||||
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
answer(new Response(JSON.stringify({ access_token: "new", refresh_token: "new-r", expires_in: 3600 }), { status: 200 }));
|
||||
await expect(renewal).rejects.toThrow("disconnected or replaced while it was being renewed");
|
||||
expect(h.ciphertexts()).toEqual([0]);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a reconnect made while Google answers keeps the new connection and erases only the old sign-in", async () => {
|
||||
let answer: (response: Response) => void = () => {};
|
||||
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
|
||||
const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" });
|
||||
const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro");
|
||||
const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" }));
|
||||
answer(new Response("", { status: 200 }));
|
||||
await expect(disconnecting).rejects.toThrow("was reconnected while it was being disconnected");
|
||||
expect(h.connection.db.prepare("select enabled from providers where id = ?").get(h.providerId)).toEqual({ enabled: 1 });
|
||||
expect(h.ciphertexts()).toEqual([0, signIn("1//new").length]);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a reconnect and a refused revocation at once are both reported", async () => {
|
||||
let answer: (response: Response) => void = () => {};
|
||||
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
|
||||
const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" });
|
||||
const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro");
|
||||
const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" }));
|
||||
answer(new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } }));
|
||||
const failure = (await disconnecting.catch((error: unknown) => error)) as Error;
|
||||
expect(failure.message).toContain("was reconnected while it was being disconnected");
|
||||
expect(failure.message).toContain("did not confirm revoking the sign-in (Google revocation failed: 400");
|
||||
expect(failure.message).toContain("revoke it in your account");
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("credentials retired before this release are erased by the migration's statement", () => {
|
||||
const h = company("openai", "sk-old", "gpt-5.4");
|
||||
h.connection.db.prepare("update providers set credential_id = null where id = ?").run(h.providerId);
|
||||
h.connection.db.prepare("update provider_auth_profiles set active = 0").run();
|
||||
h.connection.db.exec(ERASE_RETIRED_CREDENTIALS_SQL);
|
||||
expect(h.ciphertexts()).toEqual([0]);
|
||||
h.connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user