mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
fix(stage-tamagotchi): incorrect auth callback state validation (#2042)
Authored-by-agent: Codex
This commit is contained in:
@@ -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>', {
|
||||
|
||||
Reference in New Issue
Block a user