fix(auth): read enumerated issuers exactly, and stop the TUI tone going stale (#2144)
Copilot review round 4. - `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that issuer holds none — right for a connect, wrong for `collectGrants`, which labelled the fallback with the issuer it happened to be iterating and paired it with that issuer's credentials. During a partially migrated flow the clear could have sent an old, unbound token to a newly discovered authorization server. `OAuthStorage.getIssuerTokens` is the exact, no-fallback read; only the ctx-less read still falls back, which is the one place it belongs. - The TUI's warning tone was sticky. It lived in its own state while ~30 call sites set ordinary OAuth notes without resetting it, so one revocation failure left every later "Authorization updated" yellow, across server switches. It is now DERIVED — `oauthMessageToneFor(message, warningText)` — so it cannot outlive the message that earned it, with nothing to remember to clear. Both setters stay plain `useState` setters: a `useCallback` wrapper would be a value `react-hooks/exhaustive-deps` demands in seven dependency arrays for an identity that never changes. - Hook-level tests for the web wiring (previously missed): the per-server opt-out and the backend-proxied fetch are now asserted to reach `clearServerOAuthState`, so handing it the page-origin fetch — which a real authorization server's missing CORS headers would reject — fails a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
781aa69d19
commit
6c499f3ea1
6 changed files with 211 additions and 17 deletions
|
|
@ -58,6 +58,7 @@ function createStorage(
|
|||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getCodeVerifier: vi.fn(),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ function createMemoryStorage(
|
|||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as OAuthStorage;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ describe("idpSession", () => {
|
|||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -435,6 +435,42 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
).toBe("r2");
|
||||
});
|
||||
|
||||
// `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that
|
||||
// issuer holds none. Treating the fallback as the issuer's would label an
|
||||
// old, unbound token with a newly discovered authorization server and send it
|
||||
// there — so an enumerated issuer is read EXACTLY.
|
||||
it("does not attribute the legacy unkeyed token to an enumerated issuer", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: "https://as.example.com" }),
|
||||
);
|
||||
// A legacy, unbound grant...
|
||||
await storage.saveTokens(SERVER_URL, {
|
||||
access_token: "legacy-a",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "legacy-r",
|
||||
});
|
||||
// ...and an issuer slot carrying only client information, which is the
|
||||
// shape a partially-migrated flow leaves behind.
|
||||
await storage.saveClientInformation(
|
||||
SERVER_URL,
|
||||
{ client_id: "cid" },
|
||||
{ registrationKind: "dcr", issuer: "https://as.example.com" },
|
||||
);
|
||||
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
await revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn });
|
||||
|
||||
// Exactly one request — the legacy grant, read ctx-lessly and unlabelled.
|
||||
// Never two, and never the legacy token presented as that issuer's.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
|
||||
).toBe("legacy-r");
|
||||
});
|
||||
|
||||
// A grant bound to an issuer the cached metadata does not describe cannot be
|
||||
// revoked — that endpoint belongs to a different authorization server, and
|
||||
// sending it another AS's token would hand a credential to a server that
|
||||
|
|
|
|||
|
|
@ -396,6 +396,41 @@ describe("BrowserOAuthStorage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The no-fallback read #2144 needs: `getTokens(url, issuer)` deliberately
|
||||
// falls back to the legacy unkeyed slot, which is right for a connect and
|
||||
// wrong for anything enumerating issuers.
|
||||
describe("getIssuerTokens", () => {
|
||||
it("returns only the tokens bound to that issuer", async () => {
|
||||
await storage.saveTokens(
|
||||
testServerUrl,
|
||||
{ access_token: "a", token_type: "Bearer" },
|
||||
{ issuer: "https://as-a.example.com" },
|
||||
);
|
||||
const tokens = await storage.getIssuerTokens(
|
||||
testServerUrl,
|
||||
"https://as-a.example.com",
|
||||
);
|
||||
expect(tokens?.access_token).toBe("a");
|
||||
});
|
||||
|
||||
it("does not fall back to the legacy unkeyed token", async () => {
|
||||
await storage.saveTokens(testServerUrl, {
|
||||
access_token: "legacy",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
// `getTokens` DOES fall back — that contrast is the point of the method.
|
||||
expect((await storage.getTokens(testServerUrl))?.access_token).toBe(
|
||||
"legacy",
|
||||
);
|
||||
expect(
|
||||
await storage.getIssuerTokens(
|
||||
testServerUrl,
|
||||
"https://as-a.example.com",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearServerState", () => {
|
||||
it("should clear all state for a server", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ function createMockParams(
|
|||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
listIssuers: vi.fn().mockResolvedValue([]),
|
||||
getIssuerTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
saveDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
clearDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
Loading…
Reference in a new issue