fix(auth): harden Steam enroll resume and desktop Steam hygiene

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lulu
2026-07-28 13:28:35 +08:00
co-authored by Cursor
parent 5c3c683465
commit 55573c56eb
34 changed files with 1059 additions and 99 deletions
+8
View File
@@ -123,6 +123,14 @@ jobs:
exit 1
fi
# https://github.com/moeru-ai/airi/pull/1966#discussion_r3611541082
# Steam packages bake VITE_SERVER_URL to the Railway dev API. Until
# branch→API mapping exists, only internal-test may be set live.
if [[ "$STEAM_RELEASE_BRANCH" != "internal-test" ]]; then
echo "::error::Steam release branch must be \"internal-test\" while packages target the Railway dev API (got: $STEAM_RELEASE_BRANCH)."
exit 1
fi
build:
name: Build
needs: validate-release-inputs
+32 -16
View File
@@ -18,6 +18,7 @@ import { checkEmailIdentifier } from './email-identifier'
import { createElectronCallbackRelay } from './oidc/electron-callback'
import { createOIDCTokenAuthRoute } from './oidc/token-auth'
import { createSteamDesktopSignInRoute } from './steam/desktop-sign-in'
import { attachEnrollTokenToTrustedLoginRedirect } from './steam/enroll-token-login-redirect'
import { createAuthUiRoutes } from './ui-routes'
function usesRailwayEdge(apiServerUrl: string): boolean {
@@ -101,27 +102,42 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
// redirects to login) WITHOUT consuming the token, so a user whose
// session expired mid-enrollment can still complete linking after they
// re-authenticate — the token survives until its 10m TTL.
//
// Better Auth only restores the cleaned authorize query into the login
// continuation. Re-attach enrollToken onto trusted login redirects so
// the second authorize attempt can still find the enrollment row.
if (resolved?.user && !isUserBannedNow(resolved.user)) {
const payload = await consumeEnrollmentToken(deps.db, enrollToken)
if (payload) {
try {
await linkSteamToUser(deps.db, {
userId: resolved.user.id,
steamId: payload.steamId,
profile: payload.profile,
})
}
catch {
// Link failed: do not issue a code. The browser sees a 403; the
// Electron loopback times out and surfaces a retry toast. The token
// is already consumed (single-use) so the user relaunches Steam for a
// fresh enrollment handoff.
throw createForbiddenError('Steam enrollment failed — please relaunch AIRI', 'STEAM_ENROLLMENT_LINK_FAILED')
}
if (!payload) {
throw createForbiddenError(
'Steam enrollment expired or invalid — please relaunch AIRI',
'STEAM_ENROLLMENT_TOKEN_INVALID',
)
}
try {
await linkSteamToUser(deps.db, {
userId: resolved.user.id,
steamId: payload.steamId,
profile: payload.profile,
})
}
catch {
// Link failed: do not issue a code. The browser sees a 403; the
// Electron loopback times out and surfaces a retry toast. The token
// is already consumed (single-use) so the user relaunches Steam for a
// fresh enrollment handoff.
throw createForbiddenError('Steam enrollment failed — please relaunch AIRI', 'STEAM_ENROLLMENT_LINK_FAILED')
}
return handleAuthRequest(cleanedRequest)
}
return handleAuthRequest(cleanedRequest)
const authResponse = await handleAuthRequest(cleanedRequest)
return attachEnrollTokenToTrustedLoginRedirect(authResponse, enrollToken, {
apiServerUrl: deps.env.API_SERVER_URL,
authUiUrl: deps.env.AUTH_UI_URL,
})
})
// NOTICE:
// `/api/auth/*` bypasses sessionMiddleware (and thus the ban gate in
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { attachEnrollTokenToTrustedLoginRedirect } from './enroll-token-login-redirect'
describe('attachEnrollTokenToTrustedLoginRedirect', () => {
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3610763521
it('re-attaches enrollToken on a relative /auth/sign-in redirect (PR #1966)', () => {
const response = new Response(null, {
status: 302,
headers: {
location: '/auth/sign-in?client_id=airi-stage-electron&response_type=code',
},
})
const next = attachEnrollTokenToTrustedLoginRedirect(response, 'tok-enroll', {
apiServerUrl: 'http://localhost:3000',
authUiUrl: 'https://accounts.airi.build/ui',
})
expect(next.headers.get('location')).toBe(
'/auth/sign-in?client_id=airi-stage-electron&response_type=code&enrollToken=tok-enroll',
)
})
it('re-attaches enrollToken on the standalone auth UI sign-in redirect (PR #1966)', () => {
const response = new Response(null, {
status: 302,
headers: {
location: 'https://accounts.airi.build/ui/sign-in?client_id=airi-stage-electron&response_type=code',
},
})
const next = attachEnrollTokenToTrustedLoginRedirect(response, 'tok-enroll', {
apiServerUrl: 'http://localhost:3000',
authUiUrl: 'https://accounts.airi.build/ui',
})
expect(next.headers.get('location')).toBe(
'https://accounts.airi.build/ui/sign-in?client_id=airi-stage-electron&response_type=code&enrollToken=tok-enroll',
)
})
it('does not attach enrollToken to non-login redirects (PR #1966)', () => {
const response = new Response(null, {
status: 302,
headers: {
location: '/api/auth/oidc/electron-callback?code=ac_1&state=43123:opaque',
},
})
const next = attachEnrollTokenToTrustedLoginRedirect(response, 'tok-enroll', {
apiServerUrl: 'http://localhost:3000',
authUiUrl: 'https://accounts.airi.build/ui',
})
expect(next.headers.get('location')).toBe(
'/api/auth/oidc/electron-callback?code=ac_1&state=43123:opaque',
)
})
it('does not attach enrollToken to untrusted login hosts (PR #1966)', () => {
const response = new Response(null, {
status: 302,
headers: {
location: 'https://attacker.example/ui/sign-in?client_id=x',
},
})
const next = attachEnrollTokenToTrustedLoginRedirect(response, 'tok-enroll', {
apiServerUrl: 'http://localhost:3000',
authUiUrl: 'https://accounts.airi.build/ui',
})
expect(next.headers.get('location')).toBe('https://attacker.example/ui/sign-in?client_id=x')
})
})
@@ -0,0 +1,85 @@
import { resolveAuthUiUrl } from '../../../utils/auth-ui'
/**
* Re-attaches a Steam enrollment bearer token onto a Better Auth login
* redirect after authorize stripped it for the OIDC validator.
*
* Only mutates redirects that target this deployment's `/auth/sign-in` entry
* or the configured standalone auth UI sign-in page. Other Locations (codes,
* consent, attackers) are returned unchanged.
*/
export function attachEnrollTokenToTrustedLoginRedirect(
response: Response,
enrollToken: string,
options: { apiServerUrl: string, authUiUrl: string },
): Response {
if (response.status < 300 || response.status >= 400)
return response
const location = response.headers.get('location')
if (!location)
return response
const base = new URL(options.apiServerUrl)
let redirected: URL
try {
redirected = new URL(location, base)
}
catch {
return response
}
if (!isTrustedLoginRedirect(redirected, options))
return response
redirected.searchParams.set('enrollToken', enrollToken)
const headers = new Headers(response.headers)
headers.set('location', formatRedirectLocation(location, redirected, base))
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
function isTrustedLoginRedirect(
redirected: URL,
options: { apiServerUrl: string, authUiUrl: string },
): boolean {
const apiOrigin = new URL(options.apiServerUrl).origin
const authUiBase = resolveAuthUiUrl(options.authUiUrl, options.apiServerUrl)
let authUi: URL
try {
authUi = new URL(authUiBase)
}
catch {
return false
}
const authUiBasePath = authUi.pathname.replace(/\/+$/, '')
const path = redirected.pathname.replace(/\/+$/, '') || '/'
if (redirected.origin === apiOrigin && path === '/auth/sign-in')
return true
if (redirected.origin === authUi.origin && path === `${authUiBasePath}/sign-in`)
return true
return false
}
/**
* Prefer keeping relative Locations relative so Better Auth / reverse-proxy
* behavior stays unchanged; absolute Locations stay absolute.
*/
function formatRedirectLocation(originalLocation: string, redirected: URL, apiBase: URL): string {
if (/^https?:\/\//i.test(originalLocation))
return redirected.toString()
if (redirected.origin !== apiBase.origin)
return redirected.toString()
return `${redirected.pathname}${redirected.search}${redirected.hash}`
}
@@ -132,25 +132,102 @@ describe('authorize enrollment choke point', () => {
expect(users[0]?.image).toBe('https://x/a.jpg')
})
it('issues a code without linking when the token is invalid (Steam stays unlinked)', async () => {
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3600204293
it('rejects an invalid enrollment token for PR #1966', async () => {
const { app, handler } = await buildRoutes(db, { sessionUser: { id: 'uid_ok', banned: false } })
// ROOT CAUSE:
//
// An authenticated request with an invalid, expired, or consumed token
// skipped linking but still reached Better Auth after enrollToken was
// stripped, which issued an OIDC code for a half-completed enrollment.
//
// Before the fix, this request returned a 302 authorization redirect.
//
// We fixed this by failing before Better Auth whenever token consumption
// returns no enrollment payload.
const res = await app.request(authorizeUrl('not-a-real-token'), { headers: { cookie: 'session=tok' } })
expect(res.status).toBe(302)
expect(handler).toHaveBeenCalledTimes(1)
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'STEAM_ENROLLMENT_TOKEN_INVALID' })
expect(handler).not.toHaveBeenCalled()
const accounts = await db.select().from(account).where(eq(account.providerId, 'steam'))
expect(accounts).toHaveLength(0)
})
it('issues a code without linking when there is no session (token preserved for retry)', async () => {
const { app, handler } = await buildRoutes(db, { sessionUser: null })
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3610763521
it('preserves enrollToken across a trusted login redirect when there is no session (PR #1966)', async () => {
// ROOT CAUSE:
//
// Without a session the middleware stripped enrollToken before Better Auth
// built the login continuation. After login, authorize resumed without the
// token, so Steam never linked even though the DB row still existed.
//
// Before the fix, a no-session authorize that redirected to login dropped
// enrollToken from Location.
//
// We fixed this by re-attaching enrollToken only onto trusted login
// redirects, then completing link+code on the authenticated retry.
const handler = vi.fn(async (req: Request) => {
const url = new URL(req.url)
expect(url.searchParams.has('enrollToken')).toBe(false)
return new Response(null, {
status: 302,
headers: {
location: `/auth/sign-in?${url.searchParams.toString()}`,
},
})
})
const token = await createEnrollmentToken(db, { steamId: '76561198000000052', profile: null })
const res = await app.request(authorizeUrl(token), { headers: {} })
expect(res.status).toBe(302)
expect(handler).toHaveBeenCalledTimes(1)
const accounts = await db.select().from(account).where(eq(account.providerId, 'steam'))
expect(accounts).toHaveLength(0)
// Token must survive a no-session attempt so the user can retry after login.
const tokens = await db.select().from(verification).where(eq(verification.id, token))
expect(tokens).toHaveLength(1)
const noSessionDeps: AuthRoutesDeps = {
auth: {
handler,
api: { getSession: vi.fn(async () => null) },
} as any,
db,
env: {
API_SERVER_URL: 'http://localhost:3000',
AUTH_UI_URL: 'https://accounts.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any,
configKV: createConfigKV(),
rateLimitMetrics: null,
}
const noSessionRoutes = await createAuthRoutes(noSessionDeps)
const noSessionApp = new Hono().route('/', noSessionRoutes)
const loginRedirect = await noSessionApp.request(authorizeUrl(token), { headers: {} })
expect(loginRedirect.status).toBe(302)
const loginLocation = new URL(loginRedirect.headers.get('location')!, 'http://localhost:3000')
expect(loginLocation.pathname).toBe('/auth/sign-in')
expect(loginLocation.searchParams.get('enrollToken')).toBe(token)
const accountsBefore = await db.select().from(account).where(eq(account.providerId, 'steam'))
expect(accountsBefore).toHaveLength(0)
const tokensBefore = await db.select().from(verification).where(eq(verification.id, token))
expect(tokensBefore).toHaveLength(1)
const { app: withSessionApp, handler: withSessionHandler } = await buildRoutes(db, {
sessionUser: { id: 'uid_retry', banned: false },
})
const linked = await withSessionApp.request(authorizeUrl(token), { headers: { cookie: 'session=tok' } })
expect(linked.status).toBe(302)
expect(withSessionHandler).toHaveBeenCalledTimes(1)
expect(withSessionHandler.mock.calls[0][0].url).not.toContain('enrollToken')
const accounts = await db.select().from(account).where(and(
eq(account.providerId, 'steam'),
eq(account.accountId, '76561198000000052'),
))
expect(accounts).toHaveLength(1)
expect(accounts[0]?.userId).toBe('uid_retry')
const tokensAfter = await db.select().from(verification).where(eq(verification.id, token))
expect(tokensAfter).toHaveLength(0)
})
})
@@ -87,6 +87,34 @@ describe('enrollment token', () => {
expect(await consumeEnrollmentToken(db, 'does-not-exist')).toBeNull()
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3611541086
// ROOT CAUSE:
//
// Token consumption deleted by verification row ID before checking the
// reserved Steam identifier prefix. A caller could therefore consume an
// unrelated verification flow's row even though this function returned null.
//
// Before the patch, the foreign row below was deleted.
//
// We fixed this by including the Steam identifier prefix in the atomic
// delete predicate.
it('does not delete a foreign verification token (PR #1966)', async () => {
const now = new Date()
await db.insert(verification).values({
id: 'foreign-verification-token',
identifier: 'email-verification:user@example.com',
value: 'foreign-verification-value',
expiresAt: new Date(now.getTime() + 60_000),
createdAt: now,
updatedAt: now,
})
expect(await consumeEnrollmentToken(db, 'foreign-verification-token')).toBeNull()
const rows = await db.select().from(verification).where(eq(verification.id, 'foreign-verification-token'))
expect(rows).toHaveLength(1)
expect(rows[0]?.identifier).toBe('email-verification:user@example.com')
})
it('returns null for an expired token (and deletes it)', async () => {
const token = await createEnrollmentToken(db, { steamId: '76561198000000024', profile: null })
// Force expiry by backdating the row.
@@ -2,7 +2,7 @@ import type { Database } from '../../../libs/db'
import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { and, eq, like } from 'drizzle-orm'
import { verification } from '../../../schemas/accounts'
@@ -76,14 +76,15 @@ export async function createEnrollmentToken(
* - The OIDC authorize choke point resolves the authenticated session and is
* about to issue a code; it consumes the token and links Steam first.
*
* The DELETE-RETURNING makes consumption single-use across concurrent
* requests; expiry + identifier prefix are validated post-delete so an
* already-used / expired / foreign token resolves to `null`.
* The DELETE-RETURNING matches both the row id and reserved identifier prefix,
* keeping consumption single-use across concurrent requests without touching
* other verification flows. Expiry is validated post-delete so stale Steam
* enrollment rows are cleaned up while resolving to `null`.
*
* Returns:
* - The bound `{ steamId, profile }` when the token was valid and is now
* consumed, else `null` (row already gone, expired, or not an enrollment
* token). In all non-null cases the row is deleted.
* token). Foreign verification rows are left untouched.
*/
export async function consumeEnrollmentToken(
db: Database,
@@ -91,7 +92,10 @@ export async function consumeEnrollmentToken(
): Promise<EnrollmentTokenPayload | null> {
const deleted = await db
.delete(verification)
.where(eq(verification.id, token))
.where(and(
eq(verification.id, token),
like(verification.identifier, `${ENROLLMENT_IDENTIFIER_PREFIX}%`),
))
.returning({
identifier: verification.identifier,
value: verification.value,
@@ -103,8 +107,6 @@ export async function consumeEnrollmentToken(
return null
if (new Date(row.expiresAt).getTime() <= Date.now())
return null
if (!row.identifier.startsWith(ENROLLMENT_IDENTIFIER_PREFIX))
return null
const steamId = row.identifier.slice(ENROLLMENT_IDENTIFIER_PREFIX.length)
let profile: SteamProfile | null = null
@@ -77,4 +77,35 @@ describe('linkSteamToUser', () => {
const accounts = await db.select().from(account).where(and(eq(account.providerId, 'steam'), eq(account.accountId, '76561198000000033')))
expect(accounts).toHaveLength(1)
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3600204294
it('rejects a SteamID that belongs to another user for PR #1966', async () => {
const ownerUserId = await createUser(db)
const otherUserId = await createUser(db)
const steamId = '76561198000000034'
await linkSteamToUser(db, { userId: ownerUserId, steamId, profile: null })
// ROOT CAUSE:
//
// The existing-account lookup selected only the row ID and treated every
// match as an idempotent retry, even when another AIRI user owned the row.
//
// Before the fix, this call resolved successfully.
//
// We fixed this by selecting the owner and accepting only same-user retries.
await expect(linkSteamToUser(db, {
userId: otherUserId,
steamId,
profile: null,
})).rejects.toThrow('Steam account is already linked to another user')
const accounts = await db
.select({ userId: account.userId })
.from(account)
.where(and(eq(account.providerId, 'steam'), eq(account.accountId, steamId)))
expect(accounts).toHaveLength(1)
expect(accounts[0]?.userId).toBe(ownerUserId)
})
})
@@ -16,10 +16,9 @@ const STEAM_PROVIDER_ID = 'steam'
* - The OIDC authorize choke point has consumed a valid enrollment token and
* resolved the session user; linking happens atomically with code issuance.
*
* Idempotent: if a `steam` account row already exists for this steamId
* (linked to any user), the insert + profile backfill are skipped. This
* closes the race where a second enrollment attempt converges on a steamId
* that a concurrent request already linked.
* Idempotent only when the `steam` account row already belongs to the same
* AIRI user. A row owned by another user is rejected so enrollment cannot
* claim an existing identity.
*
* Profile application: nickname/avatar are written ONLY to user fields that
* are currently empty (the 2026-06-13 "write-if-empty" semantics), so
@@ -30,13 +29,15 @@ export async function linkSteamToUser(
params: { userId: string, steamId: string, profile?: SteamProfile | null },
): Promise<void> {
const [existing] = await db
.select({ id: account.id })
.select({ userId: account.userId })
.from(account)
.where(and(eq(account.providerId, STEAM_PROVIDER_ID), eq(account.accountId, params.steamId)))
.limit(1)
if (existing)
if (existing?.userId === params.userId)
return
if (existing)
throw new Error('Steam account is already linked to another user')
const now = new Date()
await db.insert(account).values({
@@ -8,8 +8,6 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -44,6 +44,14 @@ else {
console.warn('[electron-builder/config] Xcode version is 26 or above. Using .icon format for macOS app icon.')
}
// NOTICE:
// Steam loads an unsigned libsteam_api.dylib from beside the .app, so only
// Steam builds disable hardened-runtime library validation.
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3642547795
const macEntitlementsFile = process.env.VITE_DISTRIBUTION === 'steam'
? 'build/entitlements.mac.steam.plist'
: 'build/entitlements.mac.plist'
export default {
appId: 'ai.moeru.airi',
productName: 'AIRI',
@@ -169,8 +177,8 @@ export default {
runAfterFinish: true,
},
mac: {
entitlements: 'build/entitlements.mac.plist',
entitlementsInherit: 'build/entitlements.mac.plist',
entitlements: macEntitlementsFile,
entitlementsInherit: macEntitlementsFile,
// NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves
// to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`).
publish: {
@@ -0,0 +1,40 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveUserDataPath } from './userData'
describe('resolveUserDataPath', () => {
it('keeps the Electron default for direct builds', () => {
expect(resolveUserDataPath({
defaultPath: join('app-data', 'AIRI'),
distribution: 'direct',
})).toBeUndefined()
})
it('prefers an explicit operational override', () => {
expect(resolveUserDataPath({
defaultPath: join('app-data', 'AIRI'),
distribution: 'steam',
overridePath: ` ${join('test-data', 'airi')} `,
})).toBe(join('test-data', 'airi'))
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3432862899
it('isolates Steam user data from direct installations for PR #1966', () => {
// ROOT CAUSE:
//
// Steam and direct builds both accepted Electron's default userData path,
// so a Steam launch could restore credentials and local state written by a
// direct installation before startup Steam authentication ran.
//
// Before the fix, this returned undefined and kept the shared default.
//
// We fixed this by deriving a Steam-only sibling directory while keeping
// the explicit APP_USER_DATA_PATH override authoritative.
expect(resolveUserDataPath({
defaultPath: join('app-data', 'AIRI'),
distribution: 'steam',
})).toBe(join('app-data', 'AIRI-steam'))
})
})
@@ -0,0 +1,31 @@
import { basename, dirname, join } from 'node:path'
/**
* Selects an explicit Electron user-data directory when the runtime requests
* one. Returning `undefined` preserves Electron's default directory.
*
* `APP_USER_DATA_PATH` is an operational override used by smoke tests and
* remains authoritative over build-distribution policy. Steam builds use a
* sibling directory so channel-local credentials, plugins, settings, and
* caches cannot be restored from a direct installation.
*/
export function resolveUserDataPath(params: {
defaultPath: string
distribution?: string
overridePath?: string
}): string | undefined {
const overridePath = params.overridePath?.trim()
if (overridePath)
return overridePath
if (params.distribution === 'steam') {
// Derive from Electron's platform-specific default instead of hardcoding
// macOS, Windows, or Linux application-data locations.
return join(
dirname(params.defaultPath),
`${basename(params.defaultPath)}-steam`,
)
}
return undefined
}
+9 -1
View File
@@ -23,6 +23,7 @@ import icon from '../../resources/icon.png?asset'
import { openDebugger, setupDebugger } from './app/debugger'
import { nullFileLoggerHandle, setupFileLogger } from './app/file-logger'
import { installSingleInstanceGuard } from './app/single-instance'
import { resolveUserDataPath } from './app/userData'
import { createArtistryConfig } from './configs/artistry'
import { createGlobalAppConfig } from './configs/global'
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
@@ -38,6 +39,7 @@ import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
import { setupAutoUpdater } from './services/electron/auto-updater'
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
import { setupMediaPermissionHandlers } from './services/electron/media-permissions'
import { shutdownSteam } from './services/steam/client'
import { setupTray } from './tray'
import { setupAboutWindowReusable } from './windows/about'
import { setupBeatSync } from './windows/beat-sync'
@@ -64,7 +66,11 @@ setupDebugger()
const log = useLogg('main').useGlobalConfig()
const appUserDataPath = env.APP_USER_DATA_PATH?.trim()
const appUserDataPath = resolveUserDataPath({
defaultPath: app.getPath('userData'),
distribution: import.meta.env.VITE_DISTRIBUTION,
overridePath: env.APP_USER_DATA_PATH,
})
if (appUserDataPath) {
app.setPath('userData', appUserDataPath)
}
@@ -335,6 +341,8 @@ async function handleAppExit() {
await Promise.all([
logIfError('execute onAppBeforeQuit hooks', () => emitAppBeforeQuit()),
logIfError('stop injeca', () => injeca.stop()),
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3622685118
logIfError('shut down Steam SDK', () => shutdownSteam()),
])
// Prevent the global log hook from trying to write to the file after close() is called,
@@ -0,0 +1,132 @@
import type { SteamExchangeResult } from './steam-sign-in'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
cancelWebApiTicket,
getWebApiTicket,
initSteam,
} from '../steam/client'
import { trySteamSignIn } from './auth'
import { exchangeSteamTicketForTokens } from './steam-sign-in'
vi.mock('../steam/client', () => ({
cancelWebApiTicket: vi.fn(),
getWebApiTicket: vi.fn(),
initSteam: vi.fn(),
}))
vi.mock('./steam-sign-in', () => ({
exchangeSteamTicketForTokens: vi.fn(),
}))
// NOTICE:
// The main-process auth module imports Electron's `shell` runtime boundary.
// This Vitest suite runs in Node and must not load the native Electron runtime.
// Source/context: `apps/stage-tamagotchi/src/main/services/airi/auth.ts`.
// Removal condition: run this suite inside an Electron-enabled Vitest runtime.
vi.mock('electron', () => ({
shell: { openExternal: vi.fn() },
}))
const cancelWebApiTicketMock = vi.mocked(cancelWebApiTicket)
const exchangeSteamTicketForTokensMock = vi.mocked(exchangeSteamTicketForTokens)
const getWebApiTicketMock = vi.mocked(getWebApiTicket)
const initSteamMock = vi.mocked(initSteam)
describe('trySteamSignIn', () => {
beforeEach(() => {
vi.clearAllMocks()
initSteamMock.mockResolvedValue({ ok: true })
getWebApiTicketMock.mockResolvedValue({
ok: true,
authTicket: 73,
ticketHex: 'deadbeef',
})
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3610725557
// ROOT CAUSE:
//
// The Steam client discarded the Web API ticket handle, so the sign-in
// orchestration could not cancel it after `/desktop-sign-in` completed.
// Successful tickets accumulated in the SDK until shutdown and remained
// valid longer than the one server exchange that needed them.
//
// Before the patch, there was no cancellation after the exchange.
//
// We fixed this by retaining the handle and cancelling it in `finally`, so
// success, enrollment, server errors, and thrown failures share one cleanup.
it('cancels the Web API ticket after the server exchange completes (PR #1966)', async () => {
let finishExchange: ((result: SteamExchangeResult) => void) | undefined
exchangeSteamTicketForTokensMock.mockImplementation(async () => {
return await new Promise<SteamExchangeResult>((resolve) => {
finishExchange = resolve
})
})
const windowAuthManager = {
registerWindow: vi.fn(),
broadcastAuthCallback: vi.fn(),
broadcastAuthError: vi.fn(),
}
const signIn = trySteamSignIn(windowAuthManager)
await vi.waitFor(() => {
expect(exchangeSteamTicketForTokensMock).toHaveBeenCalledWith({
serverUrl: 'https://api.airi.build',
ticketHex: 'deadbeef',
})
})
expect(cancelWebApiTicketMock).not.toHaveBeenCalled()
finishExchange?.({
ok: true,
tokens: {
accessToken: 'access-token',
expiresIn: 3600,
},
})
await signIn
expect(cancelWebApiTicketMock).toHaveBeenCalledWith(73)
expect(windowAuthManager.broadcastAuthCallback).toHaveBeenCalledWith({
accessToken: 'access-token',
expiresIn: 3600,
})
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3642770345
it('ignores a concurrent steam sign-in while ticket exchange is in flight (PR #1966)', async () => {
let finishExchange: ((result: SteamExchangeResult) => void) | undefined
exchangeSteamTicketForTokensMock.mockImplementation(async () => {
return await new Promise<SteamExchangeResult>((resolve) => {
finishExchange = resolve
})
})
const windowAuthManager = {
registerWindow: vi.fn(),
broadcastAuthCallback: vi.fn(),
broadcastAuthError: vi.fn(),
}
const first = trySteamSignIn(windowAuthManager)
await vi.waitFor(() => {
expect(exchangeSteamTicketForTokensMock).toHaveBeenCalledTimes(1)
})
await trySteamSignIn(windowAuthManager)
expect(exchangeSteamTicketForTokensMock).toHaveBeenCalledTimes(1)
expect(getWebApiTicketMock).toHaveBeenCalledTimes(1)
finishExchange?.({
ok: true,
tokens: {
accessToken: 'access-token',
expiresIn: 3600,
},
})
await first
})
})
@@ -1,6 +1,8 @@
import type { createContext } from '@moeru/eventa/adapters/electron/main'
import type { BrowserWindow } from 'electron'
import type { SteamExchangeResult } from './steam-sign-in'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { errorMessageFrom } from '@moeru/std'
@@ -17,7 +19,7 @@ import {
electronAuthLogout,
electronAuthStartLogin,
} from '../../../shared/eventa'
import { getWebApiTicket, initSteam } from '../steam/client'
import { cancelWebApiTicket, getWebApiTicket, initSteam } from '../steam/client'
import { startLoopbackServer } from './http-server/http/auth'
import { exchangeSteamTicketForTokens } from './steam-sign-in'
@@ -35,6 +37,8 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token'
// Active loopback server cleanup handle
let closeLoopback: (() => void) | null = null
let signingInFlight = false
/** Serializes Steam ticket exchange + enrollment handoff across entry points. */
let steamSignInInFlight = false
export interface TokenExchangeResult {
accessToken: string
@@ -213,35 +217,55 @@ async function startSteamSignIn(
windowAuthManager: WindowAuthManager,
options: { openBrowserOnNeedsEnrollment: boolean },
): Promise<void> {
const ticketResult = await getWebApiTicket()
if (!ticketResult.ok) {
windowAuthManager.broadcastAuthError(ticketResult.reason)
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3642770345
// Ticket exchange must be single-flight: concurrent calls would each mint an
// enrollment token and createEnrollmentToken() deletes the prior unused row.
if (steamSignInInFlight) {
log.warn('Ignoring concurrent Steam sign-in while another exchange is in flight')
return
}
const exchangeResult = await exchangeSteamTicketForTokens({
serverUrl: SERVER_URL,
ticketHex: ticketResult.ticketHex,
})
if (!exchangeResult.ok) {
if (exchangeResult.kind === 'needs_enrollment') {
if (options.openBrowserOnNeedsEnrollment) {
await startEnrollmentFlow(windowAuthManager, {
enrollToken: exchangeResult.enrollToken,
authUiUrl: exchangeResult.authUiUrl,
})
}
// Startup path discards the token; the user's click re-fetches a fresh
// one so the short TTL covers only the browser → verify → relay window.
steamSignInInFlight = true
try {
const ticketResult = await getWebApiTicket()
if (!ticketResult.ok) {
windowAuthManager.broadcastAuthError(ticketResult.reason)
return
}
windowAuthManager.broadcastAuthError(exchangeResult.reason)
return
}
windowAuthManager.broadcastAuthCallback(exchangeResult.tokens)
log.log('Steam sign-in successful')
let exchangeResult: SteamExchangeResult
try {
exchangeResult = await exchangeSteamTicketForTokens({
serverUrl: SERVER_URL,
ticketHex: ticketResult.ticketHex,
})
}
finally {
cancelWebApiTicket(ticketResult.authTicket)
}
if (!exchangeResult.ok) {
if (exchangeResult.kind === 'needs_enrollment') {
if (options.openBrowserOnNeedsEnrollment) {
await startEnrollmentFlow(windowAuthManager, {
enrollToken: exchangeResult.enrollToken,
authUiUrl: exchangeResult.authUiUrl,
})
}
// Startup path discards the token; the user's click re-fetches a fresh
// one so the short TTL covers only the browser → verify → relay window.
return
}
windowAuthManager.broadcastAuthError(exchangeResult.reason)
return
}
windowAuthManager.broadcastAuthCallback(exchangeResult.tokens)
log.log('Steam sign-in successful')
}
finally {
steamSignInInFlight = false
}
}
/**
@@ -291,4 +291,12 @@ describe('setupAutoUpdater', () => {
await service.quitAndInstall()
expect(updaterState.instance.quitAndInstall).toHaveBeenCalledWith()
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3619097247
it('disables the github updater feed for steam builds (PR #1966)', async () => {
const { shouldDisableGitHubUpdater } = await import('./auto-updater')
expect(shouldDisableGitHubUpdater('steam')).toBe(true)
expect(shouldDisableGitHubUpdater('direct')).toBe(false)
expect(shouldDisableGitHubUpdater(undefined)).toBe(false)
})
})
@@ -268,7 +268,69 @@ function isPrereleaseVersion(version: string) {
return (semver.prerelease(version)?.length ?? 0) > 0
}
/** Steam depot builds must not follow the GitHub electron-updater feed. */
export function shouldDisableGitHubUpdater(
distribution: string | undefined = import.meta.env.VITE_DISTRIBUTION,
): boolean {
return distribution === 'steam'
}
function createDisabledAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater {
const hooks = new Set<(state: AutoUpdaterState) => void>()
let state: AutoUpdaterState = { status: 'idle' }
let storedPreferredLane = options.getStoredUpdateLane?.()
function broadcast(next: AutoUpdaterState) {
state = next
for (const hook of hooks) {
try {
hook(state)
}
catch {}
}
}
return {
get state() {
return state
},
async checkForUpdates() {
broadcast({ status: 'idle' })
},
async downloadUpdate() {},
async quitAndInstall() {},
getPreferredUpdateLane() {
return storedPreferredLane
},
async setPreferredUpdateLane(lane) {
if (storedPreferredLane === lane)
return
storedPreferredLane = lane
options.setStoredUpdateLane?.(lane)
broadcast({ status: 'idle' })
},
subscribe(callback) {
hooks.add(callback)
try {
callback(state)
}
catch {}
return () => {
hooks.delete(callback)
}
},
}
}
export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater {
// NOTICE:
// Steam depot builds must not consume the GitHub electron-updater feed. A
// user who installs that update would replace the Steam-packaged binary and
// lose Steam redistributables / silent Steam sign-in.
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3619097247
if (shouldDisableGitHubUpdater())
return createDisabledAutoUpdater(options)
const semaphore = new Semaphore(1)
const appVersion = app.getVersion()
const isPrereleaseBuild = isPrereleaseVersion(appVersion)
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
cancelWebApiTicket,
getWebApiTicket,
initSteam,
resetSteamClientForTests,
@@ -9,6 +10,7 @@ import {
import { STEAM_APP_ID } from './types'
const steamMock = vi.hoisted(() => {
const cancelAuthTicket = vi.fn()
const getAuthTicketForWebApi = vi.fn()
const init = vi.fn(() => true)
const shutdown = vi.fn()
@@ -17,10 +19,11 @@ const steamMock = vi.hoisted(() => {
init,
shutdown,
setSdkPath,
user: { getAuthTicketForWebApi },
user: { cancelAuthTicket, getAuthTicketForWebApi },
}))
return {
cancelAuthTicket,
getAuthTicketForWebApi,
init,
shutdown,
@@ -116,8 +119,10 @@ describe('getWebApiTicket', () => {
beforeEach(async () => {
resetSteamClientForTests()
steamMock.init.mockReturnValue(true)
steamMock.cancelAuthTicket.mockReset()
steamMock.getAuthTicketForWebApi.mockResolvedValue({
success: true,
authTicket: 73,
ticketHex: 'deadbeef',
})
await initSteam()
@@ -128,15 +133,34 @@ describe('getWebApiTicket', () => {
vi.clearAllMocks()
})
it('returns ticket hex for the configured web api identity', async () => {
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3610725557
// ROOT CAUSE:
//
// `getAuthTicketForWebApi()` returned both the ticket bytes and the
// `authTicket` handle needed by Steam's `CancelAuthTicket`, but this wrapper
// discarded the handle and exposed only `ticketHex`. Callers therefore had
// no way to cancel a successful ticket after server authentication.
//
// Before the patch: `{ ok: true, ticketHex: 'deadbeef' }`.
//
// We fixed this by preserving the handle until the caller finishes the
// `/desktop-sign-in` exchange and explicitly cancels it.
it('returns the handle required to cancel a Web API ticket (PR #1966)', async () => {
const result = await getWebApiTicket()
expect(result).toEqual({ ok: true, ticketHex: 'deadbeef' })
expect(result).toEqual({ ok: true, authTicket: 73, ticketHex: 'deadbeef' })
expect(steamMock.getAuthTicketForWebApi).toHaveBeenCalledWith({
genericString: 'airi-desktop',
})
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3610725557
it('cancels a Web API ticket through the initialized Steam SDK (PR #1966)', () => {
cancelWebApiTicket(73)
expect(steamMock.cancelAuthTicket).toHaveBeenCalledWith(73)
})
it('maps Steam API failure to ok false', async () => {
steamMock.getAuthTicketForWebApi.mockResolvedValue({
success: false,
@@ -1,3 +1,5 @@
import type { HAuthTicket } from 'steamworks-ffi-node'
import type { SteamInitResult, SteamTicketResult } from './types'
import process from 'node:process'
@@ -121,7 +123,25 @@ export async function getWebApiTicket(): Promise<SteamTicketResult> {
}
}
return { ok: true, ticketHex: result.ticketHex }
return {
ok: true,
authTicket: result.authTicket,
ticketHex: result.ticketHex,
}
}
/**
* Invalidates a Web API ticket after the server exchange that consumed it.
*
* Steam keeps successful ticket handles active until they are cancelled or
* the SDK shuts down. Calling this after each exchange limits both the native
* resource lifetime and the window in which the ticket remains valid.
*/
export function cancelWebApiTicket(authTicket: HAuthTicket): void {
if (!steamInitialized || !steam)
return
steam.user.cancelAuthTicket(authTicket)
}
export function shutdownSteam(): void {
@@ -1,3 +1,5 @@
import type { HAuthTicket } from 'steamworks-ffi-node'
export const STEAM_APP_ID = 3885340
export type SteamInitResult
@@ -5,5 +7,5 @@ export type SteamInitResult
| { ok: false, reason: 'not_steam' | 'init_failed' | 'api_unavailable' }
export type SteamTicketResult
= | { ok: true, ticketHex: string }
= | { ok: true, authTicket: HAuthTicket, ticketHex: string }
| { ok: false, reason: string }
@@ -69,6 +69,27 @@ describe('initializeElectronAuthCallbackBridge', () => {
expect(fetchSessionMock).toHaveBeenCalledTimes(1)
})
// https://github.com/moeru-ai/airi/pull/1966#pullrequestreview-4770150485
// ROOT CAUSE:
//
// Electron auth callbacks carried an ID token, but the renderer bridge only
// persisted the access and refresh tokens. The later sign-out flow therefore
// could not select the OIDC end-session path that requires an ID token hint.
//
// Before the patch, authStore.idToken remained null after this callback.
//
// We fixed this by persisting the callback ID token alongside the other OIDC
// credentials.
it('persists the ID token from an Electron auth callback (PR #1966)', async () => {
const authStore = useAuthStore()
await emit(electronAuthCallback, {
accessToken: 'access-token',
idToken: 'id-token',
expiresIn: 3600,
})
expect(authStore.idToken).toBe('id-token')
})
it('toasts the error message on auth callback error', async () => {
await emit(electronAuthCallbackError, { error: 'boom' })
expect(toastErrorMock).toHaveBeenCalledWith('boom')
@@ -29,6 +29,9 @@ export function initializeElectronAuthCallbackBridge() {
if (tokens.refreshToken) {
authStore.refreshToken = tokens.refreshToken
}
if (tokens.idToken) {
authStore.idToken = tokens.idToken
}
authStore.oidcClientId = import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron'
authStore.tokenExpiry = Date.now() + tokens.expiresIn * 1000
@@ -107,8 +107,17 @@ const description = computed(() => {
>
<span>{{ t('server.auth.signIn.action.signIn') }}</span>
</Button>
<div :class="['flex items-center justify-between text-xs text-neutral-500']">
<RouterLink to="/forgot-password" :class="['underline']">
<div
:class="[
'flex items-center text-xs text-neutral-500',
scope === 'enroll' ? 'justify-end' : 'justify-between',
]"
>
<RouterLink
v-if="scope !== 'enroll'"
to="/forgot-password"
:class="['underline']"
>
{{ t('server.auth.signIn.action.forgotPassword') }}
</RouterLink>
<button type="button" :class="['underline']" @click="flow.backToIdentify">
+1 -1
View File
@@ -24,7 +24,7 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
initAuthAnalytics()
initAuthAnalytics(window.location.href)
const pinia = createPinia()
@@ -25,6 +25,30 @@ describe('auth product analytics', () => {
posthogMocks.register.mockClear()
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3610676436
// ROOT CAUSE:
//
// Steam enrollment placed its single-use credential in `/enroll?token=...`
// and later nested the same credential as `enrollToken` inside continuation
// URLs. The auth SPA initialized PostHog before routing, so automatic and
// manually captured events could include either credential-bearing URL.
//
// Before the patch, both calls initialized PostHog and returned `true`.
//
// We fixed this by refusing to initialize auth analytics for the enrollment
// route or any URL that carries the Steam-specific `enrollToken` marker.
it('does not initialize PostHog for Steam enrollment credentials (PR #1966)', () => {
expect(initAuthAnalytics(
'https://accounts.airi.build/ui/enroll?token=single-use-token&continue=https%3A%2F%2Fapi.airi.build%2Fapi%2Fauth%2Foauth2%2Fauthorize',
)).toBe(false)
expect(initAuthAnalytics(
'https://accounts.airi.build/ui/verify-email?continueURL=https%3A%2F%2Fapi.airi.build%2Fapi%2Fauth%2Foauth2%2Fauthorize%3FenrollToken%3Dsingle-use-token',
)).toBe(false)
expect(posthogMocks.init).not.toHaveBeenCalled()
expect(posthogMocks.register).not.toHaveBeenCalled()
})
// ROOT CAUSE:
//
// The auth SPA emitted `signup_completed` before it knew the Better Auth
@@ -34,7 +58,7 @@ describe('auth product analytics', () => {
// The anonymous UI milestone must use its own name. The identified server
// event remains the only canonical `signup_completed` business fact.
it('keeps anonymous signup UI completion separate from the canonical server signup fact', () => {
expect(initAuthAnalytics()).toBe(true)
expect(initAuthAnalytics('https://accounts.airi.build/ui/sign-up')).toBe(true)
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'auth' })
trackSignupFormCompleted({ source: 'email', requires_verification: true })
+16 -3
View File
@@ -23,6 +23,7 @@ import {
POSTHOG_ENABLED,
POSTHOG_PROJECT_KEY,
} from '../../../../posthog.config'
import { buildAuthUiPath } from './auth-ui-base'
/** Login/signup credential kinds shown on the sign-in page. */
export type AuthMethod = 'email' | 'github' | 'google'
@@ -31,13 +32,25 @@ let initialized = false
/**
* Initialize PostHog for the auth surface. Call once from `main.ts` before
* mount; later calls are no-ops. Returns whether capture is active so
* callers can skip building event payloads in analytics-disabled builds.
* mount with the absolute browser URL; later calls are no-ops. Steam
* enrollment URLs never initialize analytics because they carry a single-use
* account-linking credential. Returns whether capture is active so callers can
* skip building event payloads in analytics-disabled builds.
*/
export function initAuthAnalytics(): boolean {
export function initAuthAnalytics(currentUrl: string): boolean {
if (!POSTHOG_ENABLED)
return false
const location = new URL(currentUrl)
const carriesSteamEnrollmentCredential
= location.pathname === buildAuthUiPath('/enroll')
// After the enroll page, the credential is named `enrollToken` and may
// be nested inside encoded continue/callback URLs on other auth routes.
|| location.search.includes('enrollToken')
|| location.hash.includes('enrollToken')
if (carriesSteamEnrollmentCredential)
return false
if (initialized)
return true
@@ -26,6 +26,22 @@ describe('createEnrollContext', () => {
expect(createEnrollContext('https://accounts.airi.build/ui/enroll?token=abc&continue=not-a-url')).toBeNull()
})
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3523476256
// ROOT CAUSE:
//
// The enrollment parser trusted the origin and path of the caller-controlled
// continue URL. AuthStepForms then used that origin as its API server, so the
// trusted AIRI page could submit credentials to an attacker-controlled host.
//
// Before the patch, both crafted URLs below produced an enrollment context.
//
// We fixed this by accepting only trusted AIRI API origins whose continuation
// targets the OIDC authorize endpoint.
it('rejects untrusted enrollment continuations (PR #1966)', () => {
expect(createEnrollContext('https://accounts.airi.build/ui/enroll?token=abc&continue=https://attacker.example/api/auth/oauth2/authorize')).toBeNull()
expect(createEnrollContext('https://accounts.airi.build/ui/enroll?token=abc&continue=https://api.airi.build/api/auth/sign-in/email')).toBeNull()
})
it('derives apiServerUrl from the continue origin', () => {
const ctx = createEnrollContext('https://accounts.airi.build/ui/enroll?token=abc&continue=https://api.airi.build/api/auth/oauth2/authorize?client_id=x')
expect(ctx).toEqual({
@@ -1,5 +1,7 @@
import type { CheckEmailResult } from './email-password'
import { normalizeTrustedApiServerUrl } from './server-auth-context'
export type EmailStep = 'identify' | 'password' | 'create'
/**
@@ -50,18 +52,27 @@ export function createEnrollContext(currentUrl: string): EnrollContext | null {
if (!enrollToken || !continueUrl)
return null
let continueOrigin: string
let parsedContinueUrl: URL
try {
continueOrigin = new URL(continueUrl).origin
parsedContinueUrl = new URL(continueUrl)
}
catch {
return null
}
const apiServerUrl = normalizeTrustedApiServerUrl(parsedContinueUrl.origin)
if (
!apiServerUrl
|| parsedContinueUrl.origin !== apiServerUrl
|| parsedContinueUrl.pathname !== '/api/auth/oauth2/authorize'
) {
return null
}
return {
enrollToken,
continueUrl,
apiServerUrl: continueOrigin,
apiServerUrl,
}
}
@@ -103,7 +103,18 @@ export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackA
}
}
function normalizeTrustedApiServerUrl(value: string | null): string | null {
/**
* Normalizes a trusted AIRI API server URL to its supported origin.
*
* Before:
* - `"https://api.airi.build/api/auth/oauth2/authorize"`
* - `"https://attacker.example/api/auth/oauth2/authorize"`
*
* After:
* - `"https://api.airi.build"`
* - `null`
*/
export function normalizeTrustedApiServerUrl(value: string | null): string | null {
if (!value)
return null
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
broadcastMatchesContinuation,
buildVerifyEmailBroadcastEvent,
normalizeTrustedAuthorizeContinueUrl,
shouldVerifiedSuccessTabNavigate,
} from './verify-email-resume'
const webAuthorize = 'https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&response_type=code&state=abc'
const enrollAuthorize = 'https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-electron&response_type=code&enrollToken=tok-1'
const attacker = 'https://attacker.example/api/auth/oauth2/authorize?enrollToken=tok-1'
describe('normalizeTrustedAuthorizeContinueUrl', () => {
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3622919781
it('accepts trusted authorize URLs and rejects attacker origins (PR #1966)', () => {
expect(normalizeTrustedAuthorizeContinueUrl(webAuthorize)).toBe(webAuthorize)
expect(normalizeTrustedAuthorizeContinueUrl(attacker)).toBeNull()
expect(normalizeTrustedAuthorizeContinueUrl('https://api.airi.build/ui/sign-in')).toBeNull()
expect(normalizeTrustedAuthorizeContinueUrl('not-a-url')).toBeNull()
})
})
describe('shouldVerifiedSuccessTabNavigate', () => {
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3627286380
it('lets only steam enrollment continuations self-navigate from the email tab (PR #1966)', () => {
expect(shouldVerifiedSuccessTabNavigate(enrollAuthorize)).toBe(true)
expect(shouldVerifiedSuccessTabNavigate(webAuthorize)).toBe(false)
expect(shouldVerifiedSuccessTabNavigate(attacker)).toBe(false)
})
})
describe('verify-email broadcast correlation', () => {
// https://github.com/moeru-ai/airi/pull/1966#discussion_r3642944325
it('ignores verified broadcasts for a different continuation (PR #1966)', () => {
const event = buildVerifyEmailBroadcastEvent(enrollAuthorize)
expect(event).not.toBeNull()
expect(broadcastMatchesContinuation(event, enrollAuthorize)).toBe(true)
expect(broadcastMatchesContinuation(event, webAuthorize)).toBe(false)
expect(broadcastMatchesContinuation('verified', enrollAuthorize)).toBe(false)
expect(broadcastMatchesContinuation(null, enrollAuthorize)).toBe(false)
})
})
@@ -0,0 +1,87 @@
import { normalizeTrustedApiServerUrl } from './server-auth-context'
export interface VerifyEmailBroadcastEvent {
type: 'verified'
/**
* Exact `continueURL` of the flow that completed verification.
* Pending tabs ignore events whose key does not match their own continuation.
*/
continuationKey: string
}
/**
* Accepts only trusted AIRI OIDC authorize URLs used as email-verification
* continuation targets.
*
* Before:
* - `"https://api.airi.build/api/auth/oauth2/authorize?client_id=x"`
* - `"https://attacker.example/api/auth/oauth2/authorize"`
*
* After:
* - `"https://api.airi.build/api/auth/oauth2/authorize?client_id=x"`
* - `null`
*/
export function normalizeTrustedAuthorizeContinueUrl(value: string): string | null {
if (!value)
return null
let parsed: URL
try {
parsed = new URL(value)
}
catch {
return null
}
const apiServerUrl = normalizeTrustedApiServerUrl(parsed.origin)
if (
!apiServerUrl
|| parsed.origin !== apiServerUrl
|| parsed.pathname !== '/api/auth/oauth2/authorize'
) {
return null
}
return parsed.toString()
}
/**
* Steam enrollment authorize URLs carry a single-use `enrollToken`. The email
* success tab may resume those itself (Electron has no browser PKCE). Plain
* web OIDC continuations must leave resume to the original pending tab.
*/
export function shouldVerifiedSuccessTabNavigate(continueURL: string): boolean {
const trusted = normalizeTrustedAuthorizeContinueUrl(continueURL)
if (!trusted)
return false
return new URL(trusted).searchParams.has('enrollToken')
}
export function buildVerifyEmailBroadcastEvent(continueURL: string): VerifyEmailBroadcastEvent | null {
const trusted = normalizeTrustedAuthorizeContinueUrl(continueURL)
if (!trusted)
return null
return {
type: 'verified',
continuationKey: trusted,
}
}
export function broadcastMatchesContinuation(
event: VerifyEmailBroadcastEvent | string | null | undefined,
continueURL: string,
): boolean {
if (!event || typeof event === 'string')
return false
if (event.type !== 'verified')
return false
const trusted = normalizeTrustedAuthorizeContinueUrl(continueURL)
if (!trusted)
return false
return event.continuationKey === trusted
}
+32 -11
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { VerifyEmailBroadcastEvent } from '../modules/verify-email-resume'
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useBroadcastChannel } from '@vueuse/core'
import { computed, onMounted, watch } from 'vue'
@@ -7,6 +9,12 @@ import { useRoute } from 'vue-router'
import { trackEmailVerificationCompleted, trackEmailVerificationFailed } from '../modules/analytics'
import { API_SERVER_URL_QUERY_PARAM, getServerAuthBootstrapContext } from '../modules/server-auth-context'
import {
broadcastMatchesContinuation,
buildVerifyEmailBroadcastEvent,
normalizeTrustedAuthorizeContinueUrl,
shouldVerifiedSuccessTabNavigate,
} from '../modules/verify-email-resume'
const { t } = useI18n()
const route = useRoute()
@@ -42,24 +50,34 @@ const continueURL = computed(() => {
//
// Do not poll /get-session: an abandoned pending tab would burn request quota.
//
// After verification, resume with a top-level navigation to continueURL.
// A credentials fetch to /get-session from a cross-site auth UI host cannot
// see the API session cookie (SameSite); authorize navigation can.
// Web OIDC: the verified-success tab must NOT navigate to continueURL itself
// that tab lacks the original tab's PKCE sessionStorage. It only broadcasts a
// continuation-keyed event so the matching pending tab can resume.
//
// Steam enrollment: continueURL carries enrollToken and Electron owns PKCE on
// the loopback side, so the email success tab may navigate after trust checks.
//
// pending-mount still probes get-session for same-site / already-verified reload.
type VerifyEmailEvent = 'verified'
const { post, data, isSupported } = useBroadcastChannel<VerifyEmailEvent, VerifyEmailEvent>({
const { post, data, isSupported } = useBroadcastChannel<VerifyEmailBroadcastEvent, VerifyEmailBroadcastEvent | string>({
name: 'airi-auth-verify-email',
})
function navigateToContinue(): boolean {
if (!continueURL.value)
const trusted = normalizeTrustedAuthorizeContinueUrl(continueURL.value)
if (!trusted)
return false
window.location.href = continueURL.value
window.location.href = trusted
return true
}
async function resumeIfSessionReady(source: 'pending-mount' | 'broadcast' | 'verified-success'): Promise<boolean> {
if (source === 'broadcast' || source === 'verified-success')
if (source === 'verified-success') {
if (!shouldVerifiedSuccessTabNavigate(continueURL.value))
return false
return navigateToContinue()
}
if (source === 'broadcast')
return navigateToContinue()
try {
@@ -84,8 +102,9 @@ async function resumeIfSessionReady(source: 'pending-mount' | 'broadcast' | 'ver
onMounted(async () => {
if (verified.value) {
trackEmailVerificationCompleted()
if (isSupported.value)
post('verified')
const event = buildVerifyEmailBroadcastEvent(continueURL.value)
if (isSupported.value && event)
post(event)
if (continueURL.value)
await resumeIfSessionReady('verified-success')
return
@@ -101,7 +120,9 @@ onMounted(async () => {
})
watch(data, async (event) => {
if (event !== 'verified' || verified.value || error.value)
if (verified.value || error.value)
return
if (!broadcastMatchesContinuation(event, continueURL.value))
return
await resumeIfSessionReady('broadcast')