diff --git a/.changeset/oauth-state-binding.md b/.changeset/oauth-state-binding.md new file mode 100644 index 00000000..daa0491d --- /dev/null +++ b/.changeset/oauth-state-binding.md @@ -0,0 +1,9 @@ +--- +'seamless-auth-api': patch +--- + +Bind the OAuth `state` to the client that started the sign-in (#463). The state was signed, expiring and single use, but anyone holding an unconsumed state and its provider code could finish the sign-in in another browser and sign that browser in to their own account (login CSRF, RFC 9700 section 4.7). + +`POST /oauth/:providerId/start` now also returns a random `binding`, and its SHA-256 is signed into the state. `POST /oauth/:providerId/callback` must send that `binding` back, and a callback with a missing or different one is refused with 400 `Invalid OAuth state` before the provider is contacted. The `oauth_login_failed` auth event records `state_binding_missing` or `state_binding_mismatch`. + +This breaks OAuth sign-in for clients that predate the binding. Upgrade `@seamless-auth/client`, `@seamless-auth/react` or `@seamless-auth/react-native`, which keep and send it; server adapters pass it through unchanged. A direct API integration must store `binding` from `/start` and send it with the callback. Until every client is upgraded, `OAUTH_ALLOW_UNBOUND_STATE=true` accepts a callback without one. It reopens the login CSRF while set, warns at boot, and is removed in v0.20. diff --git a/docs/configuration.md b/docs/configuration.md index a6d0f48d..c239fa39 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -115,6 +115,7 @@ then daily, and each run also logs the audit chain head as an external anchor. S | `TOTP_SECRET_ENCRYPTION_KEY` | No | falls back to the service token (deprecated, required from v0.20) | 32-byte base64url/base64/hex key for encrypted TOTP secrets. Production requires this or the service token. | | `OAUTH_STATE_SECRET` | No | falls back to the service token (deprecated, required from v0.20) | Dedicated signing secret for OAuth state. | | `DECOY_SUBJECT_SECRET` | No | falls back to the service token (deprecated, required from v0.20) | HMAC key the decoy pre-auth subjects on `POST /login` derive from. Rotating it re-maps which subject an unknown identifier gets, which is harmless: decoys are stateless and pre-auth tokens live five minutes. | +| `OAUTH_ALLOW_UNBOUND_STATE` | No | `false` | Deprecated, removed in v0.20. `true` accepts an OAuth callback without the `binding` from `/start`, for clients that predate it. Reopens login CSRF while set; see [docs/oauth.md](./oauth.md#browser-flow). | > Prefer setting dedicated secrets in production rather than relying on the service token > fallbacks. See [docs/production-operations.md](./production-operations.md). diff --git a/docs/oauth.md b/docs/oauth.md index 78a47f1e..d290c14b 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -40,11 +40,24 @@ Example provider: ## Browser Flow 1. `GET /oauth/providers` -2. `POST /oauth/:providerId/start` +2. `POST /oauth/:providerId/start`. Keep the returned `binding` with the pending sign-in (for + example in `sessionStorage`, or in memory in a native app). Never send it to the provider. 3. Redirect browser to returned `authorizationUrl`. 4. Provider redirects back to your configured callback URL with `code` and `state`. -5. `POST /oauth/:providerId/callback` -6. Seamless Auth validates state, exchanges the code, fetches userinfo, resolves the local user, and issues a Seamless Auth session. +5. `POST /oauth/:providerId/callback` with `code`, `state` and the `binding` from step 2. +6. Seamless Auth validates state and its binding, exchanges the code, fetches userinfo, resolves the local user, and issues a Seamless Auth session. + +The `binding` ties the `state` to the client that started the sign-in. The state travels through +the provider and the address bar, so without it anyone holding a state and its code could finish +the sign-in in another browser and sign that browser in to their own account (login CSRF). A +callback with a missing or different binding is refused with 400 `Invalid OAuth state`, and the +`oauth_login_failed` auth event records `state_binding_missing` or `state_binding_mismatch`. The +`@seamless-auth/client`, `@seamless-auth/react` and `@seamless-auth/react-native` SDKs keep and +send it for you; server adapters pass it through unchanged. + +While upgrading clients that predate the binding, `OAUTH_ALLOW_UNBOUND_STATE=true` accepts a +callback that carries none. It reopens login CSRF for as long as it is set, logs a warning at boot, +and is removed in v0.20. A binding that is sent is always checked. ## Redirect Policy diff --git a/openapi.json b/openapi.json index e5d7ab29..d81ca195 100644 --- a/openapi.json +++ b/openapi.json @@ -7934,7 +7934,8 @@ "example": { "provider": { "id": "string", "name": "string", "scopes": [null] }, "state": "string", - "authorizationUrl": null + "authorizationUrl": null, + "binding": "string" }, "schema": { "type": "object", @@ -7949,7 +7950,8 @@ "required": ["id", "name", "scopes"] }, "state": { "type": "string" }, - "authorizationUrl": { "type": "string", "format": "uri" } + "authorizationUrl": { "type": "string", "format": "uri" }, + "binding": { "type": "string" } }, "required": ["provider", "state", "authorizationUrl"] } @@ -8059,7 +8061,8 @@ "type": "object", "properties": { "code": { "type": "string", "minLength": 1 }, - "state": { "type": "string", "minLength": 1 } + "state": { "type": "string", "minLength": 1 }, + "binding": { "type": "string", "minLength": 1 } }, "required": ["code", "state"] } diff --git a/package-lock.json b/package-lock.json index 26a7e26b..c6721be7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.28.0", + "@seamless-auth/types": "^0.29.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -3093,12 +3093,12 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.28.0.tgz", - "integrity": "sha512-VKb83pSd0KyL608I4pqKxs0KYZNTHlc2CaPula9rtURcgj3eZDky39idSTvsRbHQTlxblvPeNLsgOBnyr+P7FQ==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.29.0.tgz", + "integrity": "sha512-Moiuw17RNZaORrrb5Wypjvl1iBj5K1PqDQkkK8ACYh9NSHD7khMyHaTsx9AzK6zOqROXgRRrIAurk9S1w2CZhA==", "license": "Apache-2.0", "dependencies": { - "zod": "^4.3.6" + "zod": "^4.5.0" }, "engines": { "node": ">=22" diff --git a/package.json b/package.json index 9969c134..e8fb430d 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.28.0", + "@seamless-auth/types": "^0.29.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 18bfe706..24503743 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -65,7 +65,11 @@ export async function startOAuthLogin(req: RouteRequest, res: Response) { const config = await getSystemConfig(); const redirectUri = await resolveOAuthRedirectUri(provider, req.body.redirectUri); const returnTo = allowedReturnTo(req.body.returnTo, config.origins); - const { state, payload: statePayload } = createOAuthState({ + const { + state, + payload: statePayload, + binding, + } = createOAuthState({ providerId: provider.id, redirectUri, ...(returnTo ? { returnTo } : {}), @@ -81,6 +85,7 @@ export async function startOAuthLogin(req: RouteRequest, res: Response) { return res.json({ provider: serializeOAuthProvider(provider), state, + binding, authorizationUrl: buildOAuthAuthorizationUrl({ provider, redirectUri, @@ -104,24 +109,26 @@ export async function startOAuthLogin(req: RouteRequest, res: Response) { export async function finishOAuthLogin(req: RouteRequest, res: Response) { const { providerId } = req.params; - const { code, state } = req.body; + const { code, state, binding } = req.body; const provider = await getOAuthProvider(providerId); if (!provider) { return res.status(404).json({ error: 'OAuth provider not found' }); } - const statePayload = consumeOAuthState(state, provider.id); + const consumed = consumeOAuthState(state, provider.id, binding); - if (!statePayload) { + if (!consumed.ok) { await AuthEventService.log({ type: 'oauth_login_failed', req, - metadata: { providerId: provider.id, reason: 'invalid_state' }, + metadata: { providerId: provider.id, reason: consumed.reason }, }); return res.status(400).json({ error: 'Invalid OAuth state' }); } + const statePayload = consumed.payload; + try { const { accessToken, idToken } = await exchangeOAuthTokens({ provider, diff --git a/src/generated/api.ts b/src/generated/api.ts index a524c932..d0842b1e 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -7940,7 +7940,8 @@ export interface paths { * ] * }, * "state": "string", - * "authorizationUrl": null + * "authorizationUrl": null, + * "binding": "string" * } */ 'application/json': { @@ -7952,6 +7953,7 @@ export interface paths { state: string; /** Format: uri */ authorizationUrl: string; + binding?: string; }; }; }; @@ -8074,6 +8076,7 @@ export interface paths { 'application/json': { code: string; state: string; + binding?: string; }; }; }; diff --git a/src/server.ts b/src/server.ts index 7087d9f4..8983f75e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ import { connectToDb } from './db.js'; import { initializeModels } from './models/index.js'; import { startAuditMaintenance } from './services/auditRetention.js'; import { initializeMetadataService } from './services/metadataServiceBootstrap.js'; +import { warnIfUnboundOAuthStateAllowed } from './services/oauthService.js'; import { assertSchemaCurrent } from './services/schemaGuard.js'; import getLogger from './utils/logger.js'; import { initDevSigningKey } from './utils/signingKeyStore.js'; @@ -28,6 +29,7 @@ async function startServer() { // First, so conflicting service token env vars stop boot before anything connects. assertServiceTokenConfig(); assertDevModeIsLocal(); + warnIfUnboundOAuthStateAllowed(); const models = await initializeModels(); diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts index 8638186d..6261c996 100644 --- a/src/services/oauthService.ts +++ b/src/services/oauthService.ts @@ -35,9 +35,17 @@ export type OAuthStatePayload = { redirectUri: string; returnTo?: string; nonce: string; + /** SHA-256 of the binding returned to the client that started the sign-in. */ + bindingHash?: string; createdAt: number; }; +export type OAuthStateRefusal = + 'invalid_state' | 'state_binding_missing' | 'state_binding_mismatch'; + +export type ConsumedOAuthState = + { ok: true; payload: OAuthStatePayload } | { ok: false; reason: OAuthStateRefusal }; + export type OAuthProfile = { subject: string; email: string; @@ -128,6 +136,26 @@ function purgeConsumedStates(now = Date.now()) { } } +/** + * Accepts a callback that carries no binding. Only for clients older than the binding, + * during an upgrade: without it a state can be finished by anyone who holds it. + */ +export function unboundOAuthStateAllowed(env: Record = process.env) { + return env.OAUTH_ALLOW_UNBOUND_STATE?.trim().toLowerCase() === 'true'; +} + +export function warnIfUnboundOAuthStateAllowed( + env: Record = process.env, +) { + if (!unboundOAuthStateAllowed(env)) return; + + logger.warn( + 'OAUTH_ALLOW_UNBOUND_STATE is set, so an OAuth callback without a binding is accepted and ' + + 'a sign-in can be finished by a client other than the one that started it. Upgrade every ' + + 'client to send the binding and unset it. It is deprecated and is removed in v0.20.', + ); +} + function pkceEnabled(provider: OAuthProviderConfig) { return provider.pkce !== false; } @@ -219,25 +247,37 @@ export async function resolveOAuthRedirectUri( } /** - * The signed state, and the payload that went into it. + * The signed state, the payload that went into it, and the binding. * - * Both, because the caller needs the nonce and the rest of the payload to build the + * The payload too, because the caller needs the nonce and the rest of it to build the * authorization URL. Returning only the string meant verifying it back immediately: * a second HMAC, a decode, a parse and a re-check of fields the caller had just set. + * + * The binding goes only to the client that asked to start, never to the provider, and + * the callback must present it. The state alone travels through the provider and the + * browser's address bar, so without the binding anyone holding a state and its code + * could finish the sign-in in another browser and sign that browser in to their + * account (login CSRF, RFC 9700 section 4.7). The PKCE verifier is derived from the + * payload, so PKCE does not bind the flow to a client by itself. */ -export function createOAuthState(payload: Omit): { +export function createOAuthState( + payload: Omit, +): { state: string; payload: OAuthStatePayload; + binding: string; } { + const binding = randomBytes(32).toString('base64url'); const statePayload: OAuthStatePayload = { ...payload, nonce: randomBytes(16).toString('base64url'), + bindingHash: sha256Base64Url(binding), createdAt: Date.now(), }; const encodedPayload = base64UrlEncode(JSON.stringify(statePayload)); const signature = signPayload(encodedPayload); - return { state: `${encodedPayload}.${signature}`, payload: statePayload }; + return { state: `${encodedPayload}.${signature}`, payload: statePayload, binding }; } export function verifyOAuthState(state: string, providerId: string): OAuthStatePayload | null { @@ -262,22 +302,47 @@ export function verifyOAuthState(state: string, providerId: string): OAuthStateP return payload; } -export function consumeOAuthState(state: string, providerId: string): OAuthStatePayload | null { +function checkOAuthStateBinding( + payload: OAuthStatePayload, + binding: string | undefined, +): OAuthStateRefusal | null { + if (!binding) { + return unboundOAuthStateAllowed() ? null : 'state_binding_missing'; + } + + if (typeof payload.bindingHash !== 'string') return 'state_binding_mismatch'; + + return safeEqual(sha256Base64Url(binding), payload.bindingHash) ? null : 'state_binding_mismatch'; +} + +/** + * Checked before the replay cache records the state, so a callback presenting the + * wrong binding cannot use up a state its rightful client has yet to finish. + */ +export function consumeOAuthState( + state: string, + providerId: string, + binding?: string, +): ConsumedOAuthState { const payload = verifyOAuthState(state, providerId); - if (!payload) return null; + if (!payload) return { ok: false, reason: 'invalid_state' }; + + const refusal = checkOAuthStateBinding(payload, binding); + + if (refusal) return { ok: false, reason: refusal }; purgeConsumedStates(); const stateHash = hashStateForReplayCache(state); if (consumedStateHashes.has(stateHash)) { - return null; + return { ok: false, reason: 'invalid_state' }; } consumedStateHashes.set(stateHash, payload.createdAt + STATE_TTL_MS); - return payload; + return { ok: true, payload }; } export function clearOAuthStateReplayCache() { diff --git a/tests/integration/oauth/oauth.spec.ts b/tests/integration/oauth/oauth.spec.ts index c79ed5a3..58c8b4b5 100644 --- a/tests/integration/oauth/oauth.spec.ts +++ b/tests/integration/oauth/oauth.spec.ts @@ -115,6 +115,42 @@ describe('OAuth routes', () => { expect(res.body.authorizationUrl).toContain('code_challenge_method=S256'); }); + it('returns a binding for the client that started the sign-in, never to the provider', async () => { + const res = await request(app).post('/oauth/google/start').send({ + redirectUri: 'http://localhost:5174/oauth/callback', + }); + + expect(res.status).toBe(200); + expect(res.body.binding).toEqual(expect.any(String)); + expect(res.body.authorizationUrl).not.toContain(res.body.binding); + expect(res.body.state).not.toContain(res.body.binding); + }); + + it.each([ + ['without the binding', undefined, 'state_binding_missing'], + ['with another binding', 'A'.repeat(43), 'state_binding_mismatch'], + ])('refuses a callback %s before contacting the provider', async (_label, binding, reason) => { + const start = await request(app).post('/oauth/google/start').send({ + redirectUri: 'http://localhost:5174/oauth/callback', + }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const res = await request(app) + .post('/oauth/google/callback') + .send({ code: 'attacker-code', state: start.body.state, binding }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid OAuth state'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'oauth_login_failed', + metadata: { providerId: 'google', reason }, + }), + ); + }); + it('ignores a returnTo that does not match an allowed origin', async () => { const res = await request(app).post('/oauth/google/start').send({ redirectUri: 'http://localhost:5174/oauth/callback', @@ -180,7 +216,7 @@ describe('OAuth routes', () => { const res = await request(app) .post('/oauth/google/callback') - .send({ code: 'oauth-code', state: start.body.state }); + .send({ code: 'oauth-code', state: start.body.state, binding: start.body.binding }); expect(res.status).toBe(200); expect(res.body.returnTo).toBe('http://localhost:5174/dashboard'); @@ -215,7 +251,7 @@ describe('OAuth routes', () => { const res = await request(app) .post('/oauth/google/callback') - .send({ code: 'oauth-code', state: start.body.state }); + .send({ code: 'oauth-code', state: start.body.state, binding: start.body.binding }); expect(res.status).toBe(200); expect(res.body).not.toHaveProperty('returnTo'); @@ -261,6 +297,7 @@ describe('OAuth routes', () => { const res = await request(app).post('/oauth/google/callback').send({ code: 'oauth-code', state: start.body.state, + binding: start.body.binding, }); expect(res.status).toBe(200); @@ -294,6 +331,7 @@ describe('OAuth routes', () => { const replay = await request(app).post('/oauth/google/callback').send({ code: 'oauth-code-replay', state: start.body.state, + binding: start.body.binding, }); expect(replay.status).toBe(400); @@ -366,7 +404,7 @@ describe('OAuth routes', () => { const res = await request(app) .post('/oauth/microsoft/callback') - .send({ code: 'oauth-code', state: start.body.state }); + .send({ code: 'oauth-code', state: start.body.state, binding: start.body.binding }); expect(res.status).toBe(200); expect(res.body.sub).toBe('imported-ada'); @@ -408,7 +446,7 @@ describe('OAuth routes', () => { const res = await request(app) .post('/oauth/idp/callback') - .send({ code: 'oauth-code', state: start.body.state }); + .send({ code: 'oauth-code', state: start.body.state, binding: start.body.binding }); expect(res.status).toBe(400); expect(res.body.code).toBe('oauth_invalid_id_token'); @@ -474,6 +512,7 @@ describe('OAuth routes', () => { const res = await request(app).post('/oauth/google/callback').send({ code: 'oauth-code', state: start.body.state, + binding: start.body.binding, }); expect(res.status).toBe(403); @@ -498,6 +537,7 @@ describe('OAuth routes', () => { const res = await request(app).post('/oauth/google/callback').send({ code: 'oauth-code', state: start.body.state, + binding: start.body.binding, }); expect(res.status).toBe(400); @@ -532,6 +572,7 @@ describe('OAuth routes', () => { return request(app).post('/oauth/google/callback').send({ code: 'oauth-code', state: start.body.state, + binding: start.body.binding, }); }; @@ -608,7 +649,7 @@ describe('OAuth routes', () => { return request(app) .post('/oauth/google/callback') - .send({ code: 'oauth-code', state: start.body.state }); + .send({ code: 'oauth-code', state: start.body.state, binding: start.body.binding }); } beforeEach(() => { diff --git a/tests/unit/services/oauthService.spec.ts b/tests/unit/services/oauthService.spec.ts index f937c98e..e6074039 100644 --- a/tests/unit/services/oauthService.spec.ts +++ b/tests/unit/services/oauthService.spec.ts @@ -1,5 +1,5 @@ import { createHmac } from 'crypto'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { OAuthIdentity } from '../../../src/models/oauthIdentities.js'; @@ -18,8 +18,11 @@ import { resolveOAuthRedirectUri, resolveOAuthUser, serializeOAuthProvider, + unboundOAuthStateAllowed, verifyOAuthState, + warnIfUnboundOAuthStateAllowed, } from '../../../src/services/oauthService.js'; +import getLogger from '../../../src/utils/logger.js'; function signState(payload: Record, secret: string) { const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); @@ -101,17 +104,118 @@ describe('oauthService', () => { }); it('consumes OAuth state only once per process', () => { - const { state } = createOAuthState({ + const { state, binding } = createOAuthState({ providerId: 'google', redirectUri: 'https://app.example.com/oauth/callback', }); - expect(consumeOAuthState(state, 'google')).toEqual( - expect.objectContaining({ + expect(consumeOAuthState(state, 'google', binding)).toEqual({ + ok: true, + payload: expect.objectContaining({ providerId: 'google' }), + }); + expect(consumeOAuthState(state, 'google', binding)).toEqual({ + ok: false, + reason: 'invalid_state', + }); + }); + + describe('state binding', () => { + const start = () => + createOAuthState({ providerId: 'google', - }), - ); - expect(consumeOAuthState(state, 'google')).toBeNull(); + redirectUri: 'https://app.example.com/oauth/callback', + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns a binding and signs only its hash into the state', () => { + const { state, binding, payload } = start(); + + expect(binding).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(state).not.toContain(binding); + expect(payload.bindingHash).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(payload.bindingHash).not.toBe(binding); + }); + + it('refuses a callback without the binding', () => { + const { state } = start(); + + expect(consumeOAuthState(state, 'google')).toEqual({ + ok: false, + reason: 'state_binding_missing', + }); + }); + + it('refuses a callback with another sign-in binding', () => { + const { state } = start(); + + expect(consumeOAuthState(state, 'google', 'A'.repeat(43))).toEqual({ + ok: false, + reason: 'state_binding_mismatch', + }); + }); + + it('leaves the state usable by its own client after a refused binding', () => { + const { state, binding } = start(); + + expect(consumeOAuthState(state, 'google', 'not-the-binding').ok).toBe(false); + expect(consumeOAuthState(state, 'google', binding).ok).toBe(true); + }); + + it('refuses a binding for a state signed without one', () => { + withEnv({ OAUTH_STATE_SECRET: 'explicit-secret' }, () => { + const state = signState( + { + providerId: 'google', + redirectUri: 'https://app.example.com/oauth/callback', + nonce: 'nonce', + createdAt: Date.now(), + }, + 'explicit-secret', + ); + + expect(consumeOAuthState(state, 'google', 'anything')).toEqual({ + ok: false, + reason: 'state_binding_mismatch', + }); + }); + }); + + it('accepts a callback without the binding only when unbound state is allowed', () => { + vi.stubEnv('OAUTH_ALLOW_UNBOUND_STATE', 'true'); + const { state } = start(); + + expect(consumeOAuthState(state, 'google').ok).toBe(true); + }); + + it('still checks a binding that is sent when unbound state is allowed', () => { + vi.stubEnv('OAUTH_ALLOW_UNBOUND_STATE', 'true'); + const { state } = start(); + + expect(consumeOAuthState(state, 'google', 'wrong')).toEqual({ + ok: false, + reason: 'state_binding_mismatch', + }); + }); + + it('warns at boot only while unbound state is allowed', () => { + const logger = getLogger('oauthService'); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => logger); + + warnIfUnboundOAuthStateAllowed({}); + expect(warn).not.toHaveBeenCalled(); + + warnIfUnboundOAuthStateAllowed({ OAUTH_ALLOW_UNBOUND_STATE: 'true' }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('removed in v0.20')); + }); + + it('reads the escape hatch case-insensitively and only as true', () => { + expect(unboundOAuthStateAllowed({ OAUTH_ALLOW_UNBOUND_STATE: ' TRUE ' })).toBe(true); + expect(unboundOAuthStateAllowed({ OAUTH_ALLOW_UNBOUND_STATE: '1' })).toBe(false); + expect(unboundOAuthStateAllowed({})).toBe(false); + }); }); it('builds provider authorization URLs', () => { @@ -639,7 +743,10 @@ describe('oauthService', () => { }); it('returns null when consuming an invalid state value', () => { - expect(consumeOAuthState('invalid', 'google')).toBeNull(); + expect(consumeOAuthState('invalid', 'google')).toEqual({ + ok: false, + reason: 'invalid_state', + }); }); it('purges expired replay-cache entries when consuming state', () => { @@ -648,19 +755,19 @@ describe('oauthService', () => { const base = new Date('2026-06-01T00:00:00.000Z'); vi.setSystemTime(base); - const { state: first } = createOAuthState({ + const { state: first, binding: firstBinding } = createOAuthState({ providerId: 'google', redirectUri: 'https://app.example.com/oauth/callback', }); - expect(consumeOAuthState(first, 'google')).not.toBeNull(); + expect(consumeOAuthState(first, 'google', firstBinding).ok).toBe(true); vi.setSystemTime(new Date(base.getTime() + 11 * 60 * 1000)); - const { state: second } = createOAuthState({ + const { state: second, binding: secondBinding } = createOAuthState({ providerId: 'google', redirectUri: 'https://app.example.com/oauth/callback', }); - expect(consumeOAuthState(second, 'google')).not.toBeNull(); + expect(consumeOAuthState(second, 'google', secondBinding).ok).toBe(true); } finally { vi.useRealTimers(); }