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
3 changed files with 211 additions and 17 deletions
|
|
@ -424,6 +424,17 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
await this.persist();
|
||||
}
|
||||
|
||||
async getIssuerTokens(
|
||||
serverUrl: string,
|
||||
issuer: string,
|
||||
): Promise<OAuthTokens | undefined> {
|
||||
await this.ensureLoaded();
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
const tokens = state.byIssuer?.[issuer]?.tokens;
|
||||
if (!tokens) return undefined;
|
||||
return withIssuer(await OAuthTokensSchema.parseAsync(tokens), issuer);
|
||||
}
|
||||
|
||||
async listIssuers(serverUrl: string): Promise<string[]> {
|
||||
await this.ensureLoaded();
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
|
|
|
|||
|
|
@ -357,7 +357,9 @@ interface StoredGrant {
|
|||
*
|
||||
* The ctx-less read is included last and deduped: on an issuer-bound entry it
|
||||
* returns the active issuer's token, which the loop above has already seen; on
|
||||
* a legacy entry it is the only thing that returns anything at all.
|
||||
* a legacy entry it is the only thing that returns anything at all. It is also
|
||||
* the only read here allowed to fall back — an *enumerated* issuer is read
|
||||
* exactly, so a legacy token is never mislabelled as belonging to it.
|
||||
*/
|
||||
async function collectGrants(
|
||||
storage: OAuthStorage,
|
||||
|
|
@ -367,9 +369,15 @@ async function collectGrants(
|
|||
const seen = new Set<string>();
|
||||
|
||||
const add = async (issuer?: string): Promise<void> => {
|
||||
const revocable = selectRevocableToken(
|
||||
await storage.getTokens(serverUrl, issuer),
|
||||
);
|
||||
// Exact read for an enumerated issuer. `getTokens(serverUrl, issuer)` falls
|
||||
// back to the legacy unkeyed slot when that issuer holds none, and treating
|
||||
// the fallback as the issuer's would label an old, unbound token with a
|
||||
// newly discovered authorization server and send it there.
|
||||
const tokens =
|
||||
issuer === undefined
|
||||
? await storage.getTokens(serverUrl)
|
||||
: await storage.getIssuerTokens(serverUrl, issuer);
|
||||
const revocable = selectRevocableToken(tokens);
|
||||
if (!revocable || seen.has(revocable.token)) return;
|
||||
seen.add(revocable.token);
|
||||
grants.push({
|
||||
|
|
|
|||
|
|
@ -182,6 +182,21 @@ export interface OAuthStorage {
|
|||
*/
|
||||
clearDiscoveryState(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Tokens bound to **exactly** `issuer`, with no legacy-unkeyed fallback.
|
||||
*
|
||||
* {@link getTokens} deliberately falls back to the legacy unkeyed slot when
|
||||
* the issuer slot holds none — that is what keeps a pre-SEP-2352 entry
|
||||
* working. But a caller enumerating {@link listIssuers} must not treat that
|
||||
* fallback as belonging to the issuer it happened to ask for: during a
|
||||
* partially migrated flow it would label an old, unbound token with a newly
|
||||
* discovered authorization server and send it there (#2144).
|
||||
*/
|
||||
getIssuerTokens(
|
||||
serverUrl: string,
|
||||
issuer: string,
|
||||
): Promise<OAuthTokens | undefined>;
|
||||
|
||||
/**
|
||||
* The authorization-server `issuer` keys holding credentials for this server
|
||||
* (SEP-2352). Empty when the entry predates issuer binding — its credentials
|
||||
|
|
|
|||
Loading…
Reference in a new issue