|
| 1 | +// cypress/component/resources/authorize/stale-grant-callback.cy.ts |
| 2 | +// |
| 3 | +// Regression coverage for the post-logout STALE OIDC GRANT bug: after RP-initiated logout |
| 4 | +// (cloud-portal), auth-ui's `sessions` cookie can hold a session whose Zitadel SESSION is still |
| 5 | +// alive but whose OIDC GRANT is gone. getSession succeeds (so healIfSessionDead's dead-session |
| 6 | +// 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. |
| 12 | +// |
| 13 | +// Node-bound: resolveAuthorize reads a real signed `sessions` cookie off a Request (Fetch spec |
| 14 | +// forbids a Cookie header in the browser), so this runs through the cy.task node-spec harness. |
| 15 | +import { callService, type AuditEvent } from '../../../support/node/call-service'; |
| 16 | + |
| 17 | +const SESSION = { id: 'sess-stale-grant-1', token: 'tok-stale-grant-1' }; |
| 18 | +const COOKIE = [{ id: SESSION.id, token: SESSION.token, loginName: 'alice@acme.test' }]; |
| 19 | + |
| 20 | +function authRequestSeed() { |
| 21 | + return { authRequests: { req1: { id: 'req1', clientId: 'client1', scopes: [], prompt: [] } } }; |
| 22 | +} |
| 23 | + |
| 24 | +describe('resolveOidc — stale OIDC grant after logout (createCallback failure on an ALIVE session)', () => { |
| 25 | + it('FAILED_PRECONDITION self-heals to /login (prunes the stale entry) — NOT signin_failed', () => { |
| 26 | + callService({ |
| 27 | + fn: 'resolveAuthorize', |
| 28 | + provider: 'fresh', |
| 29 | + seed: authRequestSeed(), |
| 30 | + liveSessions: [SESSION], |
| 31 | + callbackResults: { [SESSION.id]: { mode: 'throw', code: 'FAILED_PRECONDITION' } }, |
| 32 | + request: { |
| 33 | + url: 'http://localhost/id/authorize?authRequest=req1', |
| 34 | + sessions: COOKIE, |
| 35 | + }, |
| 36 | + }).then((v) => { |
| 37 | + expect(v.response?.status).to.equal(302); |
| 38 | + const loc = v.response?.location ?? ''; |
| 39 | + expect(loc).to.include('/login'); |
| 40 | + expect(loc).to.include('requestId=oidc_req1'); |
| 41 | + expect(loc).to.not.include('/error'); |
| 42 | + expect(loc).to.not.include('client.acme.test/callback'); |
| 43 | + // Stale entry pruned from the re-signed cookie (same self-heal shape as the dead-session case). |
| 44 | + expect(v.response?.cookieEntries?.some((e) => e.id === SESSION.id) ?? false).to.equal(false); |
| 45 | + // Traceable self-heal event — proves this took the heal path, not a bare error redirect. |
| 46 | + const stale = v.audit.find((e: AuditEvent) => e.event === 'session_stale'); |
| 47 | + expect(stale !== undefined, 'session_stale event').to.equal(true); |
| 48 | + expect(stale?.outcome).to.equal('success'); |
| 49 | + expect(stale?.sessionId).to.equal(SESSION.id); |
| 50 | + // The failed createCallback attempt is still logged (diagnosability), just no longer terminal. |
| 51 | + const failure = v.audit.find( |
| 52 | + (e: AuditEvent) => e.event === 'oidc_callback' && e.outcome === 'failure' |
| 53 | + ); |
| 54 | + expect(failure !== undefined, 'oidc_callback failure event').to.equal(true); |
| 55 | + expect(failure?.code).to.equal('FAILED_PRECONDITION'); |
| 56 | + }); |
| 57 | + }); |
| 58 | + |
| 59 | + it('ALREADY_DONE (the sibling code for the same underlying gRPC FailedPrecondition) also self-heals to /login', () => { |
| 60 | + callService({ |
| 61 | + fn: 'resolveAuthorize', |
| 62 | + provider: 'fresh', |
| 63 | + seed: authRequestSeed(), |
| 64 | + liveSessions: [SESSION], |
| 65 | + callbackResults: { [SESSION.id]: { mode: 'throw', code: 'ALREADY_DONE' } }, |
| 66 | + request: { |
| 67 | + url: 'http://localhost/id/authorize?authRequest=req1', |
| 68 | + sessions: COOKIE, |
| 69 | + }, |
| 70 | + }).then((v) => { |
| 71 | + expect(v.response?.status).to.equal(302); |
| 72 | + const loc = v.response?.location ?? ''; |
| 73 | + expect(loc).to.include('/login'); |
| 74 | + expect(loc).to.not.include('/error'); |
| 75 | + }); |
| 76 | + }); |
| 77 | + |
| 78 | + it('a TRANSIENT createCallback failure (UNAVAILABLE) on an alive session still surfaces signin_failed — no silent re-login', () => { |
| 79 | + callService({ |
| 80 | + fn: 'resolveAuthorize', |
| 81 | + provider: 'fresh', |
| 82 | + seed: authRequestSeed(), |
| 83 | + liveSessions: [SESSION], |
| 84 | + callbackResults: { [SESSION.id]: { mode: 'throw', code: 'UNAVAILABLE' } }, |
| 85 | + request: { |
| 86 | + url: 'http://localhost/id/authorize?authRequest=req1', |
| 87 | + sessions: COOKIE, |
| 88 | + }, |
| 89 | + }).then((v) => { |
| 90 | + expect(v.response?.status).to.equal(302); |
| 91 | + const loc = v.response?.location ?? ''; |
| 92 | + expect(loc).to.include('/error'); |
| 93 | + expect(loc).to.include('code=signin_failed'); |
| 94 | + expect(loc).to.not.include('/login'); |
| 95 | + expect(v.audit.some((e: AuditEvent) => e.event === 'session_stale')).to.equal(false); |
| 96 | + }); |
| 97 | + }); |
| 98 | +}); |
0 commit comments