From d73933b8a5270e69cba8e3b8a5a1b31fa87fac3f Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 23 Sep 2026 11:17:25 -0400 Subject: [PATCH] fix(auth): bind the OIDC popup-return proof to its purpose, browser, and popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens under the `oidc-state` scope share one signing key, so the payload is what says which job a token belongs to. The popup-return proof now carries a `purpose` claim, and each verifier accepts exactly one kind of token. The return leg also sets a single-use companion cookie, mirroring the nonce `/start` already uses, and stamps the action its redirect lands on. Redeeming a proof requires the matching cookie — consumed on success only, so a rejected call can't invalidate an in-flight return — and the popup honors only a proof minted for its own action. The cookie is host-only, so `/auth/oidc/verify-popup-return` is also served on the GUI origin and the popup redeems it same-origin. The return leg always lands the popup on `config.origin`, where both the cookie and the route live. --- .../controllers/oidc/OIDCController.test.ts | 134 ++++++++++++++++-- .../controllers/oidc/OIDCController.ts | 72 +++++++--- src/backend/services/auth/OIDCService.test.ts | 37 ++++- src/backend/services/auth/OIDCService.ts | 21 ++- src/gui/src/initgui.js | 1 + src/gui/src/util/popupOidcReturn.js | 23 +-- src/gui/src/util/popupOidcReturn.test.js | 76 +++++----- 7 files changed, 282 insertions(+), 82 deletions(-) diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index 71944e733..9bbc392a4 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -952,7 +952,6 @@ describe('OIDCController login callback', () => { makeReq({ query: { code: 'c', state } }), res, ); - expect(captured.cookies).toHaveLength(1); expect(captured.redirectUrl).toContain('embedded_in_popup=true'); expect(captured.redirectUrl).toContain('oidc_login=true'); @@ -966,6 +965,15 @@ describe('OIDCController login callback', () => { const user = await server.stores.user.getByEmail(email); expect(user?.uuid).toBeTruthy(); expect(proof?.user_uuid).toBe(user!.uuid); + + // Session cookie plus the proof's binding companion. + expect(captured.cookies).toHaveLength(2); + const binding = captured.cookies.find( + (c) => c.name === 'puter_oidc_popup_return', + ); + expect(binding?.value).toBeTruthy(); + expect(proof?.nonce).toBe(binding!.value); + expect(proof?.action).toBe('sign-in'); }); it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => { @@ -1017,6 +1025,14 @@ describe('OIDCController login callback', () => { expect(captured.redirectUrl).toContain( `opener_origin=${encodeURIComponent('http://opener.test')}`, ); + + // The error leg must stamp the action its own redirect names. + const url = new URL(captured.redirectUrl!); + const proof = oidc().verifyPopupReturn( + url.searchParams.get('opener_state')!, + ); + expect(proof?.oidc_login).toBe(false); + expect(proof?.action).toBe(url.searchParams.get('action')); }); it('links an OIDC identity to an existing CONFIRMED password account via email match', async () => { @@ -1835,19 +1851,34 @@ describe('OIDCController GET /auth/revalidate-done', () => { * attested rather than read. */ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { - const redeem = async (opener_state: unknown) => { + const NONCE = 'binding-nonce'; + + const mint = (payload: Record) => + server.services.oidc.signPopupReturn({ + nonce: NONCE, + action: 'sign-in', + ...payload, + }); + + const redeemWith = (openerState: unknown, nonce: string | null = NONCE) => + makeReq({ + body: { opener_state: openerState }, + cookies: nonce === null ? {} : { puter_oidc_popup_return: nonce }, + }); + + const redeem = async (openerState: unknown) => { const { res, captured } = makeRes(); await callRoute( 'post', '/auth/oidc/verify-popup-return', - makeReq({ body: { opener_state } }), + redeemWith(openerState), res, ); return captured; }; it('hands back what a genuine proof attests', async () => { - const proof = server.services.oidc.signPopupReturn({ + const proof = mint({ opener_origin: 'https://opener.test', msg_id: '77', oidc_login: true, @@ -1856,6 +1887,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { expect(captured.body).toEqual({ opener_origin: 'https://opener.test', msg_id: '77', + action: 'sign-in', oidc_login: true, user_uuid: null, }); @@ -1864,7 +1896,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { it('hands back the account a proof is bound to', async () => { // The popup compares this against its current user to reject a proof // replayed from another account's login. - const proof = server.services.oidc.signPopupReturn({ + const proof = mint({ opener_origin: 'https://opener.test', msg_id: '77', oidc_login: true, @@ -1875,9 +1907,13 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { }); it('rejects a proof signed with someone else’s key', async () => { - // The whole point: only the server can mint one of these. const forged = jwt.sign( - { opener_origin: 'https://console.puter.com', oidc_login: true }, + { + opener_origin: 'https://console.puter.com', + oidc_login: true, + purpose: 'popup-return', + nonce: NONCE, + }, 'not-the-server-secret', { keyid: 'v2' }, ); @@ -1885,7 +1921,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { callRoute( 'post', '/auth/oidc/verify-popup-return', - makeReq({ body: { opener_state: forged } }), + redeemWith(forged), makeRes().res, ), ).rejects.toMatchObject({ statusCode: 400 }); @@ -1896,14 +1932,19 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { // redeemed on the very next request, so a stale one is never genuine. const stale = server.services.token.sign( 'oidc-state', - { opener_origin: 'https://opener.test', oidc_login: true }, + { + opener_origin: 'https://opener.test', + oidc_login: true, + purpose: 'popup-return', + nonce: NONCE, + }, { expiresIn: -600 }, ); await expect( callRoute( 'post', '/auth/oidc/verify-popup-return', - makeReq({ body: { opener_state: stale } }), + redeemWith(stale), makeRes().res, ), ).rejects.toMatchObject({ statusCode: 400 }); @@ -1915,7 +1956,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { callRoute( 'post', '/auth/oidc/verify-popup-return', - makeReq({ body: { opener_state: bad } }), + redeemWith(bad), makeRes().res, ), ).rejects.toMatchObject({ statusCode: 400 }); @@ -1925,13 +1966,82 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => { it('reports oidc_login false when the proof does not claim a login', async () => { // The error leg mints one of these: a real return, but nothing was // signed in on it, so it must not suppress the account picker. - const proof = server.services.oidc.signPopupReturn({ + const proof = mint({ opener_origin: 'https://opener.test', msg_id: '77', oidc_login: false, }); expect((await redeem(proof)).body).toMatchObject({ oidc_login: false }); }); + + it('rejects an authorization state presented as a proof', async () => { + const state = server.services.oidc.signState({ + provider: 'google', + redirect_uri: 'https://puter.test/action/sign-in', + embedded_in_popup: true, + msg_id: '77', + opener_origin: 'https://opener.test', + nonce: NONCE, + }); + await expect( + callRoute( + 'post', + '/auth/oidc/verify-popup-return', + redeemWith(state), + makeRes().res, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a proof redeemed without its binding cookie', async () => { + const proof = mint({ + opener_origin: 'https://opener.test', + msg_id: '77', + oidc_login: true, + }); + for (const cookie of [null, 'a-different-nonce']) { + await expect( + callRoute( + 'post', + '/auth/oidc/verify-popup-return', + redeemWith(proof, cookie), + makeRes().res, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + } + }); + + it('clears the binding cookie so a proof redeems once', async () => { + const proof = mint({ + opener_origin: 'https://opener.test', + msg_id: '77', + oidc_login: true, + }); + const { res, captured } = makeRes(); + await callRoute( + 'post', + '/auth/oidc/verify-popup-return', + redeemWith(proof), + res, + ); + expect(captured.clearedCookies).toContainEqual( + expect.objectContaining({ name: 'puter_oidc_popup_return' }), + ); + }); + + it('leaves the binding cookie in place when redemption fails', async () => { + // A junk POST must not consume a cookie an in-flight return needs. + const { res, captured } = makeRes(); + await expect( + callRoute( + 'post', + '/auth/oidc/verify-popup-return', + redeemWith('not.a.proof'), + res, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(captured.clearedCookies).toEqual([]); + }); }); // ── rate-limit scopes ─────────────────────────────────────────────── diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index a73f9a3bf..749ab1c9e 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -38,6 +38,14 @@ const REVALIDATION_EXPIRY_SEC = 300; const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce'; const OIDC_NONCE_EXPIRY_SEC = 600; +// Single-use, and binds a popup-return proof to the browser that earned it. +// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService. +const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return'; +const OIDC_POPUP_RETURN_EXPIRY_SEC = 300; + +// The popup branch below hard-codes this return target. +const POPUP_RETURN_ACTION = 'sign-in'; + const OIDC_ERROR_REDIRECT_MAP: Record> = { login: { account_not_found: 'signup', other: 'login' }, signup: { account_already_exists: 'login', other: 'signup' }, @@ -154,11 +162,8 @@ function buildErrorRedirectUrl( message: string, stateDecoded?: Record, requestCode?: string, - // Signs the popup-return proof. Passed in because this is a module-level - // helper with no access to services; omitted by callers that have no - // state to attest (the proof is simply absent then, and the popup falls - // back to its browser-attested sources). - signPopupReturn?: (payload: Record) => string, + // Omitted by callers with no state to attest. + mintPopupReturn?: (payload: Record) => string, ): string { const targetFlow = OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow; @@ -202,13 +207,15 @@ function buildErrorRedirectUrl( // Same reasoning as the success leg: the popup cannot tell a verified // `opener_origin` from a typed one, so attest it. The error leg is a // real return from the provider too — the flow failed, not the hop. - if (signPopupReturn) { + if (mintPopupReturn) { params.set( 'opener_state', - signPopupReturn({ + mintPopupReturn({ opener_origin: stateDecoded?.opener_origin ?? null, msg_id: stateDecoded?.msg_id ?? null, oidc_login: false, + // The popup checks this against the action it lands on. + action: targetFlow, }), ); } @@ -267,18 +274,21 @@ export class OIDCController extends PuterController { // A sign-in popup returning from a provider is told the opener's // origin and that a login completed. It cannot check either: the // values arrive as query parameters, and a URL built from a verified - // `state` looks exactly like one an attacker typed. The opener's + // `state` looks exactly like one anybody can type. The opener's // origin decides which app a token gets minted for, so the popup // redeems the signed proof here instead of believing the raw // parameters. // + // Served on the GUI origin too: the binding cookie is host-only. + // // Unauthenticated on purpose — it reveals nothing the caller did not - // already hand over, and a forged or expired proof yields nothing. + // already hand over, and a proof that isn't this browser's yields + // nothing. router.post( '/auth/oidc/verify-popup-return', { - subdomain: 'api', + subdomain: ['api', ''], rateLimit: { scope: 'oidc-verify-popup-return', limit: 60, @@ -293,14 +303,25 @@ export class OIDCController extends PuterController { }); } const decoded = this.services.oidc.verifyPopupReturn(proof); - if (!decoded) { + const cookieNonce = + req.cookies?.[OIDC_POPUP_RETURN_COOKIE_NAME]; + + if ( + !decoded || + typeof decoded.nonce !== 'string' || + typeof cookieNonce !== 'string' || + !constantTimeEqual(cookieNonce, decoded.nonce) + ) { throw new HttpError(400, 'Invalid `opener_state`', { legacyCode: 'bad_request', }); } + // Single-use; a rejected call must not burn the cookie. + res.clearCookie(OIDC_POPUP_RETURN_COOKIE_NAME, { path: '/' }); res.json({ opener_origin: decoded.opener_origin ?? null, msg_id: decoded.msg_id ?? null, + action: decoded.action ?? null, oidc_login: decoded.oidc_login === true, user_uuid: decoded.user_uuid ?? null, }); @@ -404,7 +425,7 @@ export class OIDCController extends PuterController { : null; if (embeddedInPopup && msgId) { - appRedirectUri = `${origin}/action/sign-in?embedded_in_popup=true&msg_id=${encodeURIComponent(msgId)}`; + appRedirectUri = `${origin}/action/${POPUP_RETURN_ACTION}?embedded_in_popup=true&msg_id=${encodeURIComponent(msgId)}`; if (openerOrigin) { appRedirectUri += `&opener_origin=${encodeURIComponent(openerOrigin)}`; } @@ -522,7 +543,7 @@ export class OIDCController extends PuterController { resolutionErrorCode(resolved.code), stateDecoded, resolved.requestCode, - (p) => this.services.oidc.signPopupReturn(p), + (p) => this.#mintPopupReturn(res, p), ), ); } @@ -541,7 +562,7 @@ export class OIDCController extends PuterController { 'account_suspended', stateDecoded, undefined, - (p) => this.services.oidc.signPopupReturn(p), + (p) => this.#mintPopupReturn(res, p), ), ); } @@ -589,7 +610,7 @@ export class OIDCController extends PuterController { resolutionErrorCode(resolved.code), stateDecoded, resolved.requestCode, - (p) => this.services.oidc.signPopupReturn(p), + (p) => this.#mintPopupReturn(res, p), ), ); } @@ -605,7 +626,7 @@ export class OIDCController extends PuterController { 'account_suspended', stateDecoded, undefined, - (p) => this.services.oidc.signPopupReturn(p), + (p) => this.#mintPopupReturn(res, p), ), ); } @@ -722,6 +743,23 @@ if (window.opener) { // -- Shared helpers ---------------------------------------------- + /** Sign a popup-return proof, bound to this browser and this popup. */ + #mintPopupReturn(res: Response, payload: Record): string { + const nonce = crypto.randomBytes(32).toString('base64url'); + res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, { + // Redeemed same-origin. + ...sessionCookieFlags(this.config, { crossSite: false }), + httpOnly: true, + maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000, + path: '/', + }); + return this.services.oidc.signPopupReturn({ + action: POPUP_RETURN_ACTION, + ...payload, + nonce, + }); + } + /** * Resolve an OIDC callback to a Puter user. In order: * @@ -933,7 +971,7 @@ if (window.opener) { target = appendQueryParam( target, 'opener_state', - this.services.oidc.signPopupReturn({ + this.#mintPopupReturn(res, { opener_origin: stateDecoded.opener_origin ?? null, msg_id: stateDecoded.msg_id ?? null, oidc_login: true, diff --git a/src/backend/services/auth/OIDCService.test.ts b/src/backend/services/auth/OIDCService.test.ts index d952fa396..c83f53e50 100644 --- a/src/backend/services/auth/OIDCService.test.ts +++ b/src/backend/services/auth/OIDCService.test.ts @@ -636,7 +636,7 @@ describe('OIDCService — state tokens', () => { expect(oidc().verifyState(`${token}x`)).toBeNull(); }); - it('round-trips a popup-return proof through the same verifier', () => { + it('round-trips a popup-return proof', () => { const token = oidc().signPopupReturn({ opener_origin: 'https://app.test', logged_in: true, @@ -650,10 +650,37 @@ describe('OIDCService — state tokens', () => { it('signs a revalidation token naming the user and purpose', () => { const token = oidc().signRevalidation('user-uuid-1'); - expect(oidc().verifyState(token)).toMatchObject({ - user_uuid: 'user-uuid-1', - purpose: 'revalidate', - }); + expect(server.services.token.verify('oidc-state', token)).toMatchObject( + { + user_uuid: 'user-uuid-1', + purpose: 'revalidate', + }, + ); + }); + + it('does not accept a state or a revalidation token as a popup-return proof', () => { + expect( + oidc().verifyPopupReturn( + oidc().signState({ + provider: 'google', + opener_origin: 'https://app.test', + }), + ), + ).toBeNull(); + expect( + oidc().verifyPopupReturn(oidc().signRevalidation('user-uuid-1')), + ).toBeNull(); + }); + + it('does not accept a popup-return proof or a revalidation token as a state', () => { + expect( + oidc().verifyState( + oidc().signPopupReturn({ opener_origin: 'https://app.test' }), + ), + ).toBeNull(); + expect( + oidc().verifyState(oidc().signRevalidation('user-uuid-1')), + ).toBeNull(); }); }); diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index 8fbe2d8c1..53f3a6151 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -48,6 +48,8 @@ const STATE_EXPIRY_SEC = 600; // 10 minutes const POPUP_RETURN_EXPIRY_SEC = 300; // 5 minutes const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate'] as const; const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes +// Every `oidc-state` token shares one signing key; the payload says which job. +const POPUP_RETURN_PURPOSE = 'popup-return'; interface ProviderConfig { client_id: string; @@ -256,17 +258,28 @@ export class OIDCService extends PuterService { * redirects the popup home. */ signPopupReturn(payload: Record): string { - return this.services.token.sign('oidc-state', payload, { - expiresIn: POPUP_RETURN_EXPIRY_SEC, - }); + return this.services.token.sign( + 'oidc-state', + { ...payload, purpose: POPUP_RETURN_PURPOSE }, + { expiresIn: POPUP_RETURN_EXPIRY_SEC }, + ); } /** Verify a popup-return proof. Returns null on a bad or expired one. */ verifyPopupReturn(token: string): Record | null { - return this.verifyState(token); + const decoded = this.#verifySigned(token); + if (decoded?.purpose !== POPUP_RETURN_PURPOSE) return null; + return decoded; } verifyState(token: string): Record | null { + const decoded = this.#verifySigned(token); + // An authorization state carries no purpose. + if (!decoded || decoded.purpose !== undefined) return null; + return decoded; + } + + #verifySigned(token: string): Record | null { try { return this.services.token.verify>( 'oidc-state', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 6e16c1f64..dc3284199 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -1410,6 +1410,7 @@ window.initgui = async function (options) { window.oidcPopupReturn = await verifyOidcPopupReturn( window.url_query_params.get('opener_state'), window.url_query_params.get('msg_id'), + action, ); window.openerOrigin = window.oidcPopupReturn?.opener_origin || document.referrer; diff --git a/src/gui/src/util/popupOidcReturn.js b/src/gui/src/util/popupOidcReturn.js index 58c2cf951..3244d18cb 100644 --- a/src/gui/src/util/popupOidcReturn.js +++ b/src/gui/src/util/popupOidcReturn.js @@ -35,8 +35,8 @@ * * The return leg now carries `opener_state`, the same pair re-signed. Only the * server can produce or check that signature, so the popup redeems it here. - * A missing, forged, or expired proof yields nothing and the popup falls back - * to its browser-attested sources. + * A proof that is missing, expired, or not this browser's yields nothing and + * the popup falls back to its browser-attested sources. */ /** @@ -45,24 +45,24 @@ * @param {string|null|undefined} proof - The `opener_state` query parameter. * @param {string|null|undefined} msgId - The popup's current `msg_id`. A proof * minted for a different one belongs to another flow. + * @param {string|null|undefined} action - The popup's current action. A proof + * is minted on the sign-in return leg and is good only there. * @returns {Promise<{opener_origin: string|null, oidc_login: boolean, user_uuid: string|null}|null>} * `null` when there is no usable proof. `user_uuid` is the account that * completed OIDC; the caller must confirm it matches the current user before * treating `oidc_login` as consent to skip the account picker. */ -export const verifyOidcPopupReturn = async (proof, msgId) => { +export const verifyOidcPopupReturn = async (proof, msgId, action) => { if (!proof) return null; let attested; try { - const resp = await fetch( - `${window.api_origin}/auth/oidc/verify-popup-return`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ opener_state: proof }), - }, - ); + const resp = await fetch('/auth/oidc/verify-popup-return', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ opener_state: proof }), + }); // A rejected proof is the expected answer to a crafted link, not an // anomaly — the popup carries on with its attested sources. if (!resp.ok) return null; @@ -85,6 +85,7 @@ export const verifyOidcPopupReturn = async (proof, msgId) => { ) { return null; } + if (attested.action !== action) return null; return { opener_origin: attested.opener_origin, diff --git a/src/gui/src/util/popupOidcReturn.test.js b/src/gui/src/util/popupOidcReturn.test.js index 8f5b89c16..bee90add4 100644 --- a/src/gui/src/util/popupOidcReturn.test.js +++ b/src/gui/src/util/popupOidcReturn.test.js @@ -17,21 +17,21 @@ * along with this program. If not, see . */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { verifyOidcPopupReturn } from './popupOidcReturn.js'; const OPENER = 'https://opener.test'; /** Stand in for the verify endpoint. */ const serverSays = (body, { ok = true } = {}) => - vi.fn(async () => ({ ok, json: async () => body })); + vi.fn(async () => ({ + ok, + json: async () => ({ action: 'sign-in', ...body }), + })); -beforeEach(() => { - globalThis.window = { api_origin: 'https://api.test' }; -}); +const redeem = (proof, msgId) => verifyOidcPopupReturn(proof, msgId, 'sign-in'); afterEach(() => { - delete globalThis.window; delete globalThis.fetch; vi.restoreAllMocks(); }); @@ -43,9 +43,11 @@ describe('redeeming a proof', () => { msg_id: '7', oidc_login: true, }); - await expect(verifyOidcPopupReturn('signed.blob.here', '7')).resolves.toEqual( - { opener_origin: OPENER, oidc_login: true, user_uuid: null }, - ); + await expect(redeem('signed.blob.here', '7')).resolves.toEqual({ + opener_origin: OPENER, + oidc_login: true, + user_uuid: null, + }); }); it('passes through the account the proof is bound to', async () => { @@ -55,9 +57,7 @@ describe('redeeming a proof', () => { oidc_login: true, user_uuid: 'user-A', }); - await expect( - verifyOidcPopupReturn('signed.blob.here', '7'), - ).resolves.toEqual({ + await expect(redeem('signed.blob.here', '7')).resolves.toEqual({ opener_origin: OPENER, oidc_login: true, user_uuid: 'user-A', @@ -65,11 +65,15 @@ describe('redeeming a proof', () => { }); it('sends the proof to the verify endpoint', async () => { - const fetchMock = serverSays({ opener_origin: OPENER, oidc_login: true }); + const fetchMock = serverSays({ + opener_origin: OPENER, + oidc_login: true, + }); globalThis.fetch = fetchMock; - await verifyOidcPopupReturn('signed.blob.here', null); + await redeem('signed.blob.here', null); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.test/auth/oidc/verify-popup-return'); + expect(url).toBe('/auth/oidc/verify-popup-return'); + expect(init.credentials).toBe('include'); expect(JSON.parse(init.body)).toEqual({ opener_state: 'signed.blob.here', }); @@ -82,9 +86,7 @@ describe('redeeming a proof', () => { opener_origin: OPENER, oidc_login: false, }); - await expect( - verifyOidcPopupReturn('signed.blob.here', null), - ).resolves.toEqual({ + await expect(redeem('signed.blob.here', null)).resolves.toEqual({ opener_origin: OPENER, oidc_login: false, user_uuid: null, @@ -97,7 +99,7 @@ describe('refusing what the server did not attest', () => { // The attack shape: a crafted link naming an opener, with no OIDC round // trip behind it. Nothing is even asked of the server. globalThis.fetch = serverSays({ opener_origin: OPENER }); - await expect(verifyOidcPopupReturn(null, '7')).resolves.toBeNull(); + await expect(redeem(null, '7')).resolves.toBeNull(); expect(globalThis.fetch).not.toHaveBeenCalled(); }); @@ -107,26 +109,38 @@ describe('refusing what the server did not attest', () => { { message: 'Invalid `opener_state`' }, { ok: false }, ); - await expect( - verifyOidcPopupReturn('forged.blob', '7'), - ).resolves.toBeNull(); + await expect(redeem('forged.blob', '7')).resolves.toBeNull(); }); it('yields nothing when the attested payload carries no origin', async () => { - globalThis.fetch = serverSays({ opener_origin: null, oidc_login: true }); - await expect( - verifyOidcPopupReturn('signed.blob.here', '7'), - ).resolves.toBeNull(); + globalThis.fetch = serverSays({ + opener_origin: null, + oidc_login: true, + }); + await expect(redeem('signed.blob.here', '7')).resolves.toBeNull(); }); it('ignores a proof minted for a different popup flow', async () => { + globalThis.fetch = serverSays({ + opener_origin: OPENER, + msg_id: '7', + oidc_login: true, + }); + await expect(redeem('signed.blob.here', '8')).resolves.toBeNull(); + }); + + it('ignores a proof minted for a different popup action', async () => { globalThis.fetch = serverSays({ opener_origin: OPENER, msg_id: '7', oidc_login: true, }); await expect( - verifyOidcPopupReturn('signed.blob.here', '8'), + verifyOidcPopupReturn( + 'signed.blob.here', + '7', + 'request-permission', + ), ).resolves.toBeNull(); }); @@ -137,9 +151,7 @@ describe('refusing what the server did not attest', () => { msg_id: 7, oidc_login: true, }); - await expect( - verifyOidcPopupReturn('signed.blob.here', '7'), - ).resolves.toBeTruthy(); + await expect(redeem('signed.blob.here', '7')).resolves.toBeTruthy(); }); it('degrades to nothing when the endpoint is unreachable', async () => { @@ -149,8 +161,6 @@ describe('refusing what the server did not attest', () => { throw new Error('network down'); }); vi.spyOn(console, 'error').mockImplementation(() => {}); - await expect( - verifyOidcPopupReturn('signed.blob.here', '7'), - ).resolves.toBeNull(); + await expect(redeem('signed.blob.here', '7')).resolves.toBeNull(); }); });