fix(auth): close the fail-open issuer check and the remaining clear races (#2144)
Copilot review rounds 8 and 9. - A cached metadata document with no `issuer` was letting every issuer-bound token go to its single revocation endpoint. Absence establishes nothing, so that is now a mismatch: an issuer-bound grant must match, and only a legacy unkeyed grant — which nothing binds to a different authorization server — proceeds without the comparison. This was a credential-disclosure risk, not just a correctness one. - The ctx-less grant read was skipped whenever it carried an issuer stamp, on the assumption that slot had already been enumerated. If `listIssuers` fails there is no enumerated slot behind the stamp, so the one readable grant went unrevoked while `clear` deleted it. Both reads now share one issuer+token key, and the stamped issuer is retained rather than discarded. - `AuthTab`'s repeat guard read `clearState`, which is not updated until React re-renders, so two `s` events in one input turn both started a clear against the same store entry. It is a ref now, set before the call. - A rejected clear left `ownClearRef` set — `handleClearOAuth` never reached its `oauthRevision` bump — so the next unrelated revision was swallowed and the failure banner stranded. Cleared on that path. - The web `clearServerOAuthAndDisconnect` had the staleness the TUI path was already fixed for: `isActive`/`inspectorClient` are captured before a five-second await, so a switch during it disconnected the session the user had just moved to and ran the session-wide cleanup against it. Revalidated against `sessionRef` after each await. 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
43c817c8e9
commit
5170cc484b
1 changed files with 250 additions and 40 deletions
|
|
@ -1368,6 +1368,47 @@ describe("useOAuthRecovery", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// #2144 — the RFC 7009 leg is a bounded network request, so this callback
|
||||
// can stay suspended for seconds. `isActive`/`inspectorClient` are captured
|
||||
// before it, so without revalidating, a switch during the wait would
|
||||
// disconnect the session the user just moved to and run the session-wide
|
||||
// cleanup against it.
|
||||
it("does not disconnect a session switched to while a clear is in flight", async () => {
|
||||
let settle: (r: { cleared: boolean }) => void = () => {};
|
||||
clearServerOAuthStateMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle = resolve as typeof settle;
|
||||
}),
|
||||
);
|
||||
const client = fakeClient();
|
||||
const h = harness({
|
||||
servers: [entry("a"), entry("b")],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
|
||||
let done: Promise<void>;
|
||||
await act(async () => {
|
||||
done = h.api().clearServerOAuthAndDisconnect(entry("a"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The user switches away while the clear is still running.
|
||||
h.rerender({
|
||||
servers: [entry("a"), entry("b")],
|
||||
activeServerId: "b",
|
||||
client,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
settle({ cleared: true });
|
||||
await done;
|
||||
});
|
||||
|
||||
expect(client.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the resume snapshot on an explicit disconnect", () => {
|
||||
writeOAuthResumeSnapshot({
|
||||
version: 1,
|
||||
|
|
|
|||
Loading…
Reference in a new issue