fix(stage-tamagotchi): incorrect auth callback state validation (#2042)

Authored-by-agent: Codex
This commit is contained in:
Lovehsigure_520
2026-07-10 22:37:38 +08:00
committed by GitHub
parent f89a7eb607
commit b787e7d336
3 changed files with 67 additions and 18 deletions
@@ -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')
@@ -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<Awaited<ReturnType<typeof startLoopbackServer>>> = []
@@ -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' })
})
})
@@ -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<LoopbackCallbackResult>
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('<html><body><h2>Invalid state</h2></body></html>', {
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('<html><body><h2>Missing parameters</h2></body></html>', {
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('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>', {