mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
fix(auth): harden Steam enroll resume and desktop Steam hygiene
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user