diff --git a/apps/stage-tamagotchi/src/main/services/airi/auth.ts b/apps/stage-tamagotchi/src/main/services/airi/auth.ts index b789317bf..aed816277 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/auth.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/auth.ts @@ -105,7 +105,7 @@ export function createAuthService(params: { const state = generateState() // Start loopback server to receive the callback - const loopback = await startLoopbackServer() + const loopback = await startLoopbackServer(state) closeLoopback = loopback.close // Use the server-side relay as redirect_uri. The relay page serves HTML @@ -134,13 +134,7 @@ export function createAuthService(params: { // Wait for the callback in the background loopback.result - .then(async ({ code, state: returnedState }) => { - if (returnedState !== state) { - log.warn('State mismatch — possible CSRF attack') - params.windowAuthManager.broadcastAuthError('State mismatch') - return - } - + .then(async ({ code }) => { const tokens = await exchangeCode(code, codeVerifier, redirectUri) params.windowAuthManager.broadcastAuthCallback(tokens) log.log('OIDC token exchange successful') diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.test.ts index 1cf0219ac..f992005e3 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { startLoopbackServer } from './index' +/** + * @example + * const server = await startLoopbackServer('expected-state') + */ describe('startLoopbackServer', () => { const servers: Array>> = [] @@ -12,13 +16,45 @@ describe('startLoopbackServer', () => { } }) - it('returns code and state from callback', async () => { - const server = await startLoopbackServer() + /** @example A callback with the expected state resolves the authorization code. */ + it('returns the code from a callback with the expected state', async () => { + const server = await startLoopbackServer('state-1') servers.push(server) const response = await fetch(`http://127.0.0.1:${server.port}/callback?code=ok&state=state-1`) expect(response.status).toBe(200) - await expect(server.result).resolves.toEqual({ code: 'ok', state: 'state-1' }) + await expect(server.result).resolves.toEqual({ code: 'ok' }) + }) + + /** @example A forged callback cannot consume the one-shot server before the valid callback. */ + it('rejects a mismatched state without settling the login attempt', async () => { + const server = await startLoopbackServer('expected-state') + servers.push(server) + + const forgedResponse = await fetch(`http://127.0.0.1:${server.port}/callback?code=forged&state=wrong-state`) + expect(forgedResponse.status).toBe(400) + + const validResponse = await fetch(`http://127.0.0.1:${server.port}/callback?code=valid&state=expected-state`) + expect(validResponse.status).toBe(200) + + await expect(server.result).resolves.toEqual({ code: 'valid' }) + }) + + /** @example The web relay receives ordinary CORS without the obsolete PNA response header. */ + it('keeps standard CORS for the relay without private-network access headers', async () => { + const server = await startLoopbackServer('state-1') + servers.push(server) + + const response = await fetch(`http://127.0.0.1:${server.port}/callback?code=ok&state=state-1`, { + headers: { + Origin: 'https://accounts.airi.build', + }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(response.headers.get('Access-Control-Allow-Private-Network')).toBeNull() + await expect(server.result).resolves.toEqual({ code: 'ok' }) }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts index 738c0153f..d0076b49c 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/auth/index.ts @@ -2,9 +2,12 @@ import { eventHandler, getQuery, H3, handleCors } from 'h3' import { createH3Server } from '../../server' +/** + * Validated authorization data returned by the temporary loopback server. + */ export interface LoopbackCallbackResult { + /** Authorization code accepted only after the OIDC state matches. */ code: string - state: string } /** @@ -14,13 +17,14 @@ export interface LoopbackCallbackResult { * - Exchanging authorization code from system browser callback * * Expects: + * - `expectedState` is the high-entropy state generated for this login attempt * - Callback request on `GET /callback?code=...&state=...` - * - One-shot lifecycle; first successful callback closes the server + * - One-shot lifecycle; the first callback with matching state closes the server * * Returns: * - Random bound port, callback result promise, and manual cancellation method */ -export async function startLoopbackServer(): Promise<{ +export async function startLoopbackServer(expectedState: string): Promise<{ port: number result: Promise close: () => void @@ -47,6 +51,15 @@ export async function startLoopbackServer(): Promise<{ }, } as const + // NOTICE: + // Standard CORS lets configured web relay origins read successful handoff responses. + // A simple cross-origin GET is still sent regardless of CORS response headers, so OIDC state validation is the authorization boundary. + // Source/context: `https://developer.chrome.com/blog/local-network-access`. + // Removal condition: the relay moves to same-origin transport or top-level navigation only. + + /** + * Settles the one-shot callback result and stops the loopback listener. + */ const finish = (callback: () => void) => { if (settled) { return @@ -77,6 +90,14 @@ export async function startLoopbackServer(): Promise<{ } const query = getQuery(event) + const state = typeof query.state === 'string' ? query.state : '' + if (!state || state !== expectedState) { + return new Response('

Invalid state

', { + status: 400, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + } + const error = typeof query.error === 'string' ? query.error : undefined if (error) { const description = typeof query.error_description === 'string' && query.error_description.length > 0 @@ -92,9 +113,7 @@ export async function startLoopbackServer(): Promise<{ } const code = typeof query.code === 'string' ? query.code : '' - const state = typeof query.state === 'string' ? query.state : '' - - if (!code || !state) { + if (!code) { return new Response('

Missing parameters

', { status: 400, headers: { 'Content-Type': 'text/html; charset=utf-8' }, @@ -102,7 +121,7 @@ export async function startLoopbackServer(): Promise<{ } finish(() => { - resolveResult({ code, state }) + resolveResult({ code }) }) return new Response('

Authentication successful!

You can close this window and return to the app.

', {