From f8f01b98221189ffaaad910eb424222a3c0f994d Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 4 Aug 2026 15:45:36 +0530 Subject: [PATCH] fix: persist the mfa session cookie outside the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `credentials: 'include'` is browser-only, so node dropped every Set-Cookie the server returned. Since server 2.4.0 MFA is on by default: signup/login withhold the access token, return "Proceed to mfa setup", and identify the pending user by an mfa_session cookie. skipMfaSetup / verifyOtp / the webauthn MFA-setup path resolve it only if that cookie comes back, so all of them failed with "invalid session" — the entire MFA surface was unreachable from node while the methods existed and read as correct. Store the mfa_session cookies per instance and replay them on the graphql/rest choke point. Scoped to those cookies rather than a general jar on purpose: the server resolves identity from the `cookie` session before the Authorization header, so replaying a login session would silently override a caller-supplied bearer token. --- __test__/mfaMethods.test.ts | 51 ++++++++++++++++++++- src/index.ts | 90 +++++++++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/__test__/mfaMethods.test.ts b/__test__/mfaMethods.test.ts index 708edb6..a94cd2b 100644 --- a/__test__/mfaMethods.test.ts +++ b/__test__/mfaMethods.test.ts @@ -5,11 +5,13 @@ import { Authorizer } from '../lib'; const mockFetch = crossFetch as unknown as jest.Mock; -const jsonResponse = (body: unknown) => +const jsonResponse = (body: unknown, setCookie: string[] = []) => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)), + // cross-fetch on node is node-fetch v2, whose Headers exposes raw(). + headers: { raw: () => ({ 'set-cookie': setCookie }) }, }); describe('MFA setup/skip/lock SDK methods', () => { @@ -44,6 +46,53 @@ describe('MFA setup/skip/lock SDK methods', () => { expect(body.variables.data.state).toBe('oidc-state'); }); + // Regression: node's fetch has no cookie store, so the mfa_session cookie + // signup/login set was dropped and skipMfaSetup always failed with + // "invalid session". + it('replays the mfa_session cookie signup set on the follow-up skipMfaSetup', async () => { + const authz = new Authorizer({ + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:8080/app', + }); + mockFetch.mockReturnValueOnce( + jsonResponse( + { data: { signup: { message: 'Proceed to mfa setup', access_token: null } } }, + [ + 'mfa_session=sess-1; Path=/; Domain=localhost; Max-Age=179; HttpOnly', + 'mfa_session_domain=sess-1; Path=/; Domain=localhost; Max-Age=179; HttpOnly', + // The login session cookie must NOT be replayed: the server resolves + // identity from it before the Authorization header, so replaying it + // would override a caller-supplied bearer token. + 'cookie=login-session; Path=/; Domain=localhost; HttpOnly', + ], + ), + ); + await authz.signup({ + email: 'user@example.com', + password: 'Test@123#', + confirm_password: 'Test@123#', + }); + expect(mockFetch.mock.calls[0][1].headers.Cookie).toBeUndefined(); + + mockFetch.mockReturnValueOnce( + jsonResponse({ data: { skip_mfa_setup: { access_token: 'tok-123' } } }, [ + // consuming the session expires the cookie + 'mfa_session=; Path=/; Max-Age=0', + 'mfa_session_domain=; Path=/; Max-Age=0', + ]), + ); + const res = await authz.skipMfaSetup({ email: 'user@example.com' }); + expect(res.data?.access_token).toBe('tok-123'); + expect(mockFetch.mock.calls[1][1].headers.Cookie).toBe( + 'mfa_session=sess-1; mfa_session_domain=sess-1', + ); + + // expired cookies are dropped, not replayed + mockFetch.mockReturnValueOnce(jsonResponse({ data: { logout: {} } })); + await authz.logout(); + expect(mockFetch.mock.calls[2][1].headers.Cookie).toBeUndefined(); + }); + it('lockMfa sends email/phone_number and returns the message', async () => { mockFetch.mockReturnValueOnce( jsonResponse({ diff --git a/src/index.ts b/src/index.ts index 98d1889..6b68dc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,11 @@ const authTokenFragment = `message access_token expires_in refresh_token id_toke // set fetch based on window object. Cross fetch have issues with umd build const getFetcher = () => (hasWindow() ? window.fetch : crossFetch); +// Name prefix of the MFA-gate cookies the server sets on a token-withheld +// signup/login (`mfa_session` and its domain-scoped twin `mfa_session_domain` +// — see the backend's internal/cookie/mfa_session.go). +const MFA_COOKIE_PREFIX = 'mfa_session'; + function toErrorList(errors: unknown): Types.AuthorizerSDKError[] { if (Array.isArray(errors)) { return errors.map(toSDKError); @@ -82,6 +87,23 @@ export class Authorizer { // it can be aborted before a modal ceremony starts - the browser allows only // one outstanding navigator.credentials.get() at a time. private conditionalPasskeyAbort?: AbortController; + // MFA-session cookie store for non-browser runtimes. `credentials: + // 'include'` is a browser-only mechanism: node's fetch has no cookie store, + // so every Set-Cookie the server returns is dropped. That store is + // REQUIRED, not an optimisation - since server 2.4.0 MFA is on by default, + // so signup/login withhold the access token ("Proceed to mfa setup") and + // identify the pending user by an `mfa_session` cookie. skipMfaSetup / + // verifyOtp / the webauthn MFA-setup path resolve it only if that cookie + // comes back, so without a store they always fail with "invalid session" + // and the whole MFA surface is unreachable from node. + // + // Deliberately scoped to the MFA-gate cookies (MFA_COOKIE_PREFIX) rather + // than being a general cookie jar: the server resolves a request's identity + // from the `cookie` session BEFORE the Authorization header, so replaying a + // login session cookie would silently override the bearer token a caller + // passed explicitly. The MFA session is bound to one user id and consumed + // on use, so it cannot be traded for another user's token. + private mfaSessionCookies = new Map(); // constructor constructor(config: Types.ConfigType) { @@ -1299,7 +1321,6 @@ export class Authorizer { graphqlQuery = async ( data: Types.GraphqlQueryRequest, ): Promise => { - const fetcher = getFetcher(); const body: Record = { query: data.query, variables: data.variables || {}, @@ -1307,7 +1328,7 @@ export class Authorizer { if (data.operationName) { body.operationName = data.operationName; } - const res = await fetcher(`${this.config.authorizerURL}/graphql`, { + const res = await this.fetchWithCookies(`${this.config.authorizerURL}/graphql`, { method: 'POST', body: JSON.stringify(body), headers: { @@ -1417,8 +1438,7 @@ export class Authorizer { body?: Record, headers?: Types.Headers, ): Promise => { - const fetcher = getFetcher(); - const res = await fetcher(`${this.config.authorizerURL}${path}`, { + const res = await this.fetchWithCookies(`${this.config.authorizerURL}${path}`, { method, ...(method === 'POST' ? { body: JSON.stringify(body || {}) } : {}), headers: { @@ -1463,6 +1483,68 @@ export class Authorizer { return { data: coerceInt64Fields(json), errors: [] }; }; + // fetchWithCookies is the single choke point every graphql/rest call goes + // through. In a browser it is a plain fetch (the browser owns the cookies + // and `Cookie` is a forbidden request header anyway); elsewhere it replays + // the stored MFA session and records the one the response sets. + private fetchWithCookies = async ( + url: string, + init: Record, + ): Promise => { + const fetcher = getFetcher(); + if (hasWindow()) return fetcher(url, init as any); + + const cookie = [...this.mfaSessionCookies] + .map(([k, v]) => `${k}=${v}`) + .join('; '); + const res = await fetcher(url, { + ...init, + headers: { + // Caller-supplied headers still win, so an explicit Cookie header + // overrides the stored one. + ...(cookie ? { Cookie: cookie } : {}), + ...init.headers, + }, + } as any); + this.storeMfaSessionCookies(res); + return res; + }; + + // storeMfaSessionCookies records the MFA-gate cookies from a response. + // ponytail: name=value only - no domain/path/Secure matching, because every + // request from this instance goes to the one origin in config.authorizerURL. + private storeMfaSessionCookies = (res: any): void => { + const h = res?.headers; + // undici/whatwg expose getSetCookie(); cross-fetch on node (node-fetch v2) + // exposes raw(). `get('set-cookie')` is the last-resort single-value read. + const raw: string[] = + typeof h?.getSetCookie === 'function' + ? h.getSetCookie() + : typeof h?.raw === 'function' + ? h.raw()['set-cookie'] || [] + : h?.get?.('set-cookie') + ? [h.get('set-cookie')] + : []; + + for (const entry of raw) { + const [pair, ...attrs] = entry.split(';'); + const eq = pair.indexOf('='); + if (eq < 1) continue; + const name = pair.slice(0, eq).trim(); + if (!name.startsWith(MFA_COOKIE_PREFIX)) continue; + const value = pair.slice(eq + 1).trim(); + // The server expires a cookie by resending it empty with Max-Age<=0 + // (consuming or abandoning the mfa session); drop it rather than + // replaying a dead session id. + const expired = attrs.some((a) => { + const [k, v] = a.split('='); + return k.trim().toLowerCase() === 'max-age' && Number(v) <= 0; + }); + if (!value || expired) this.mfaSessionCookies.delete(name); + else this.mfaSessionCookies.set(name, value); + } + }; + errorResponse = (errors: unknown): Types.ApiResponse => { return { data: undefined,