fix(ai): applied broker refresh result to suspect credential snapshot

- Updated markCredentialSuspect to apply the credential entry returned from refreshCredential and throw when the broker returns a non-OAuth credential.
- Added #applyCredentialEntry to replace or append a credential in the in-memory snapshot for immediate state updates.
- Added a test covering suspect-credential refresh snapshot updates and token refresh invocation.
This commit is contained in:
can1357
2026-05-25 20:03:32 +02:00
parent 53c1494d42
commit a9a2c8516d
2 changed files with 52 additions and 2 deletions
+19 -2
View File
@@ -191,8 +191,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
}
async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
await this.#client.refreshCredential(credentialId, opts.signal);
await this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
if (entry.credential.type !== "oauth") {
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
}
this.#applyCredentialEntry(entry);
void this.refreshSnapshot().catch(error => {
logger.debug("auth-broker snapshot refresh after suspect credential refresh failed", { error: String(error) });
});
}
replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
@@ -295,6 +301,17 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
const incoming = entries.map(entry => ({ ...entry, rotatesInMs: null }));
this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
}
#applyCredentialEntry(entry: AuthCredentialSnapshotEntry): void {
const incoming = { ...entry, rotatesInMs: null };
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
if (index === -1) {
this.#snapshot = { ...this.#snapshot, credentials: [...this.#snapshot.credentials, incoming] };
return;
}
const credentials = [...this.#snapshot.credentials];
credentials[index] = incoming;
this.#snapshot = { ...this.#snapshot, credentials };
}
#removeProviderEntries(provider: string): void {
const next = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
@@ -105,6 +105,39 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
expect(refreshSpy).toHaveBeenCalledTimes(1);
clientStorage.close();
});
test("suspect credential refresh updates the client snapshot from the broker response", async () => {
const rotated = {
access: "server-access-after-401",
refresh: "server-refresh-after-401",
expires: Date.now() + 120_000,
accountId: "account-1",
email: "a@example.com",
};
const refreshSpy = vi.spyOn(oauthUtils, "refreshOAuthToken").mockResolvedValue(rotated);
const brokerClient = new AuthBrokerClient({ url: handle!.url, token });
const initialResult = await brokerClient.fetchSnapshot();
if (initialResult.status !== 200) throw new Error("expected snapshot");
const initialEntry = initialResult.snapshot.credentials[0];
if (!initialEntry) throw new Error("expected credential");
const remoteStore = new RemoteAuthCredentialStore({
client: brokerClient,
initialSnapshot: initialResult.snapshot,
});
await remoteStore.markCredentialSuspect(initialEntry.id);
const rows = remoteStore.listAuthCredentials("anthropic");
expect(rows).toHaveLength(1);
expect(rows[0]?.credential.type).toBe("oauth");
if (rows[0]?.credential.type === "oauth") {
expect(rows[0].credential.access).toBe("server-access-after-401");
expect(rows[0].credential.refresh).toBe(REMOTE_REFRESH_SENTINEL);
}
expect(refreshSpy).toHaveBeenCalledTimes(1);
remoteStore.close();
});
test("RemoteAuthCredentialStore rejects writes from the client", () => {
const remoteStore = new RemoteAuthCredentialStore({