Skip to content

Commit 66141cf

Browse files
committed
fix(authorize): don't self-heal on ALREADY_DONE — it's a double-callback, not a stale grant
The createCallback self-heal (43b5dfe) put both FAILED_PRECONDITION and ALREADY_DONE in DEAD_CALLBACK_CODES, pruning the session and bouncing to /login on either. But mappers.ts maps a code-9 FailedPrecondition whose message matches /verified|already/i ("auth request already handled") to ALREADY_DONE — the double-callback case (same requestId re-submitted after it was already finalized: browser back+reload, duplicate tab, request race), where the session is still valid. Self-healing there destroys a good session and re-threads the already-consumed requestId back to /authorize, risking a self-heal loop that keeps destroying each freshly-minted session. Only FAILED_PRECONDITION was the staging-confirmed stale-grant code, so narrow DEAD_CALLBACK_CODES to it. ALREADY_DONE now falls through to the conservative signin_failed error page with the session left intact — the strictly-safe pre-fix behavior for that case. The confirmed logout -> re-login self-heal (FAILED_PRECONDITION) is unchanged. Flip the stale-grant-callback ALREADY_DONE test to assert the corrected behavior (no prune, signin_failed, no session_stale event). Audit finding H-2 (adversarially verified).
1 parent 41675f1 commit 66141cf

2 files changed

Lines changed: 37 additions & 20 deletions

File tree

app/resources/authorize/authorize.service.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,19 @@ async function probeSession(provider: AuthProvider, entry: SessionEntry): Promis
109109
// though getSession reported it alive — a stale OIDC grant after RP-initiated logout (cloud-portal
110110
// revokes the grant but auth-ui's `sessions` cookie outlives it). Re-authenticate instead of
111111
// dead-ending on signin_failed: a fresh login mints a new session that completes the callback
112-
// (confirmed in staging — this fails ONLY for old/stale sessions; a fresh login always works, so
113-
// self-healing here cannot create a redirect loop).
114-
// • FAILED_PRECONDITION — the exact code observed in staging logs for this failure.
115-
// • ALREADY_DONE — mappers.ts normalizeError maps the SAME underlying gRPC FailedPrecondition
116-
// (code 9) to ALREADY_DONE instead of FAILED_PRECONDITION whenever Zitadel's error message
117-
// matches /verified|already/i (e.g. "auth request already handled"). createCallback goes
118-
// through that same generic mapper, so a stale grant can surface as either code depending on
119-
// Zitadel's wording — both must self-heal the same way.
120-
const DEAD_CALLBACK_CODES: ReadonlySet<ProviderError['code']> = new Set([
121-
'FAILED_PRECONDITION',
122-
'ALREADY_DONE',
123-
]);
112+
// (confirmed in staging — this fails ONLY for old/stale sessions; a fresh login always works).
113+
//
114+
// SCOPED TO FAILED_PRECONDITION ONLY — the exact code observed in staging logs for the stale-grant
115+
// failure. We deliberately do NOT self-heal on ALREADY_DONE: mappers.ts maps a code-9
116+
// FailedPrecondition whose message matches /verified|already/i ("auth request already handled") to
117+
// ALREADY_DONE, which is the DOUBLE-CALLBACK case — the SAME requestId re-submitted after it was
118+
// already finalized (browser back+reload, duplicate tab, request race). There the session is still
119+
// perfectly valid; pruning it and redirecting to /login (which re-threads the already-consumed
120+
// requestId straight back to /authorize) would destroy a good session and risk a self-heal loop
121+
// that keeps destroying each freshly-minted session. ALREADY_DONE therefore falls through to the
122+
// conservative signin_failed path below with the session left intact — the pre-fix behavior for
123+
// this case, which was strictly safe.
124+
const DEAD_CALLBACK_CODES: ReadonlySet<ProviderError['code']> = new Set(['FAILED_PRECONDITION']);
124125

125126
// Freshness window for honoring a query-handed-back sessionId against a prompt=login request.
126127
// The post-auth finalize redirect is near-immediate (the ceremony hands the just-authenticated

cypress/component/resources/authorize/stale-grant-callback.cy.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44
// (cloud-portal), auth-ui's `sessions` cookie can hold a session whose Zitadel SESSION is still
55
// alive but whose OIDC GRANT is gone. getSession succeeds (so healIfSessionDead's dead-session
66
// check never fires) and the flow proceeds to createCallback, which Zitadel rejects with
7-
// FAILED_PRECONDITION (confirmed in staging logs) — or ALREADY_DONE, the sibling code the same
8-
// gRPC FailedPrecondition maps to depending on Zitadel's error message (see mappers.ts). Before
9-
// the fix, ANY createCallback failure dead-ended on `signin_failed`; now DEAD_CALLBACK_CODES
10-
// self-heals (prune + /login) exactly like the already-covered dead-session case in logout.cy.ts,
11-
// while a genuinely transient/unknown code (e.g. UNAVAILABLE) still surfaces the error page.
7+
// FAILED_PRECONDITION (confirmed in staging logs). Before the fix, ANY createCallback failure
8+
// dead-ended on `signin_failed`; now FAILED_PRECONDITION self-heals (prune + /login) exactly like
9+
// the already-covered dead-session case in logout.cy.ts.
10+
//
11+
// IMPORTANT — ALREADY_DONE is NOT self-healed. mappers.ts maps a code-9 FailedPrecondition whose
12+
// message matches /verified|already/i ("auth request already handled") to ALREADY_DONE, which is
13+
// the DOUBLE-CALLBACK case: the SAME requestId re-submitted after it was already finalized (browser
14+
// back+reload, duplicate tab), where the session is still valid. Self-healing there would prune a
15+
// good session and re-thread the already-consumed requestId, risking a redirect loop — so
16+
// ALREADY_DONE, like any transient/unknown code (e.g. UNAVAILABLE), surfaces the conservative
17+
// signin_failed error page with the session left intact.
1218
//
1319
// Node-bound: resolveAuthorize reads a real signed `sessions` cookie off a Request (Fetch spec
1420
// forbids a Cookie header in the browser), so this runs through the cy.task node-spec harness.
@@ -56,7 +62,7 @@ describe('resolveOidc — stale OIDC grant after logout (createCallback failure
5662
});
5763
});
5864

59-
it('ALREADY_DONE (the sibling code for the same underlying gRPC FailedPrecondition) also self-heals to /login', () => {
65+
it('ALREADY_DONE (double-callback: the same auth request re-submitted after it was already finalized) does NOT self-heal — surfaces signin_failed with the still-valid session left intact', () => {
6066
callService({
6167
fn: 'resolveAuthorize',
6268
provider: 'fresh',
@@ -70,8 +76,18 @@ describe('resolveOidc — stale OIDC grant after logout (createCallback failure
7076
}).then((v) => {
7177
expect(v.response?.status).to.equal(302);
7278
const loc = v.response?.location ?? '';
73-
expect(loc).to.include('/login');
74-
expect(loc).to.not.include('/error');
79+
// Conservative error page, NOT a self-heal to /login: a double-callback must not destroy the
80+
// still-valid session or re-thread the already-consumed requestId (which risks a heal loop).
81+
expect(loc).to.include('/error');
82+
expect(loc).to.include('code=signin_failed');
83+
expect(loc).to.not.include('/login');
84+
// No self-heal event and no cookie prune — the valid session is preserved (no Set-Cookie rewrite).
85+
expect(v.audit.some((e: AuditEvent) => e.event === 'session_stale')).to.equal(false);
86+
// The failed createCallback is still logged for diagnosability, just no longer terminal-to-heal.
87+
const failure = v.audit.find(
88+
(e: AuditEvent) => e.event === 'oidc_callback' && e.outcome === 'failure'
89+
);
90+
expect(failure?.code).to.equal('ALREADY_DONE');
7591
});
7692
});
7793

0 commit comments

Comments
 (0)