feat(server): resolve or create AIRI user directly from a verified Steam ticket

Add the desktop Steam ticket sign-in endpoint (POST /api/auth/steam/desktop-sign-in): verifies a Steamworks session ticket via AuthenticateUserTicket, then resolves or creates the AIRI user for that SteamID through a shared internalAdapter-based helper extracted from the steam() plugin's OpenID callback, and bridges into a real OIDC authorization code via issueElectronOidcCode.

This replaces PR #1966's enrollToken + authorize-choke-point + raw Drizzle account-linking mechanism: since the ticket path already proves Steam identity server-side, a brand-new SteamID can get a brand-new AIRI user immediately instead of detouring through email enrollment in a browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lulu
2026-08-07 23:25:46 +08:00
co-authored by Cursor
parent e8ffa37405
commit ab29566f9d
11 changed files with 734 additions and 21 deletions
@@ -3,7 +3,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../mock-db'
import { steam } from './steam'
import { resolveOrCreateSteamUser, steam } from './steam'
import * as schema from '../../schemas'
@@ -247,3 +247,19 @@ describe('steam auth plugin', () => {
expect(stillClaimingUser?.userId).toBe(claimingUserId)
})
})
describe('resolveOrCreateSteamUser', () => {
it('creates a placeholder-email user on first call and reuses it on the next', async () => {
const auth = await createTestAuth()
const context = await auth.$context
const steamId = '76561198055555555'
const first = await resolveOrCreateSteamUser(context.internalAdapter, steamId)
const user = await context.internalAdapter.findUserById(first.userId)
expect(user?.email).toBe(`${steamId}@steam.placeholder.local`)
expect(user?.emailVerified).toBe(true)
const second = await resolveOrCreateSteamUser(context.internalAdapter, steamId)
expect(second.userId).toBe(first.userId)
})
})
+55 -15
View File
@@ -20,6 +20,60 @@ const STEAM_CLAIMED_ID_PATTERN = /^https:\/\/steamcommunity\.com\/openid\/id\/(\
// schemas would validate at runtime (better-call uses Standard Schema) but
// silently drop those OpenAPI fields. Keep these schemas in Zod until
// better-auth's OpenAPI generation supports non-Zod schemas.
/**
* The slice of better-auth's `internalAdapter` that {@link resolveOrCreateSteamUser}
* needs.
*
* NOTICE:
* The full `internalAdapter` type lives on `AuthContext` from `@better-auth/core`,
* a transitive dependency (via `better-auth`) that isn't in this package's
* `package.json`. Mirrors the narrow-local-interface pattern already used for
* `ctx.context.adapter` in `./oidc-jwt-bearer.ts` rather than adding a direct
* dependency on an internal-shaped type.
* Removal condition: `@better-auth/core` becomes a direct dependency for an
* unrelated reason, at which point this can import `InternalAdapter` from it.
*/
interface SteamAccountAdapter {
findAccountByProviderId: (accountId: string, providerId: string) => Promise<{ userId: string } | null>
createOAuthUser: (
user: { email: string, emailVerified: boolean, name: string },
account: { providerId: string, accountId: string },
) => Promise<{ user: { id: string } }>
}
/**
* Resolves the AIRI user for a verified SteamID, creating one if this is the
* SteamID's first sign-in.
*
* Use when:
* - A caller has already verified Steam identity (OpenID callback here, or a
* Steam Web API ticket on the desktop sign-in route) and needs the same
* find-or-create-user policy either way, so the two paths can never diverge
* on how a SteamID becomes an AIRI account.
*
* Identity model:
* - Mirrors the placeholder-email creation in {@link steam}'s doc comment:
* new sign-ups get `<steamid64>@steam.placeholder.local` with `emailVerified: true`.
*/
export async function resolveOrCreateSteamUser(
internalAdapter: SteamAccountAdapter,
steamId: string,
): Promise<{ userId: string }> {
const existingAccount = await internalAdapter.findAccountByProviderId(steamId, 'steam')
if (existingAccount)
return { userId: existingAccount.userId }
const { user } = await internalAdapter.createOAuthUser(
{
email: `${steamId}@steam.placeholder.local`,
emailVerified: true,
name: `Steam User ${steamId}`,
},
{ providerId: 'steam', accountId: steamId },
)
return { userId: user.id }
}
const SignInBodySchema = z.object({
callbackURL: z.string().meta({ description: 'The URL to redirect to after sign in' }),
errorCallbackURL: z.string().meta({ description: 'The URL to redirect to if an error occurs' }).optional(),
@@ -199,21 +253,7 @@ export function steam() {
throw ctx.redirect(callbackURL)
}
let userId: string
if (existingAccount) {
userId = existingAccount.userId
}
else {
const { user } = await ctx.context.internalAdapter.createOAuthUser(
{
email: `${steamId}@steam.placeholder.local`,
emailVerified: true,
name: `Steam User ${steamId}`,
},
{ providerId: 'steam', accountId: steamId },
)
userId = user.id
}
const { userId } = await resolveOrCreateSteamUser(ctx.context.internalAdapter, steamId)
const user = await ctx.context.internalAdapter.findUserById(userId)
if (!user)
+1 -1
View File
@@ -61,7 +61,7 @@ export interface TrustedClientSeedSummary {
redirectUris: string[]
}
const OIDC_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
export const OIDC_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
const OIDC_GRANT_TYPES = ['authorization_code', 'refresh_token'] as const
const OIDC_RESPONSE_TYPES = ['code'] as const
export const OIDC_CLIENT_ID_WEB = 'airi-stage-web'
+6
View File
@@ -226,6 +226,12 @@ const EnvSchema = object({
// Empty (default) = no one is admin — production safe by default.
// Example: ADMIN_EMAILS=alice@example.com,bob@example.com
ADMIN_EMAILS: optional(string(), ''),
// Steam Web API publisher key (partner.steamgames.com dashboard). Required
// by the desktop ticket sign-in endpoint to call AuthenticateUserTicket /
// CheckAppOwnership. Empty (default) makes that endpoint respond 503
// STEAM_NOT_CONFIGURED instead of failing at boot.
STEAM_PUBLISHER_KEY: optional(string(), ''),
})
export type Env = InferOutput<typeof EnvSchema>
@@ -0,0 +1,86 @@
import type { AuthInstance } from './auth'
import type { Env } from './env'
import { createHmac } from 'node:crypto'
import { generateRandomString } from 'better-auth/crypto'
import { OIDC_CLIENT_ID_ELECTRON, OIDC_SCOPES } from './auth'
/**
* Signs a better-auth session token for use in the session cookie.
*
* NOTICE:
* Mirrors `oidc-jwt-bearer` / bearer() cookie format so `/oauth2/authorize`
* accepts the session. Source: apps/server/src/libs/auth-plugins/oidc-jwt-bearer.ts
*/
function signSessionCookieValue(value: string, secret: string): string {
const signature = createHmac('sha256', secret).update(value).digest('base64')
return encodeURIComponent(`${value}.${signature}`)
}
/**
* Issues a short-lived Electron OIDC authorization code via in-process
* `/oauth2/authorize`, binding the caller's `code_challenge`.
*
* The Electron client must complete PKCE at `/oauth2/token` with the matching
* `code_verifier`. `redirect_uri` / scopes / `resource` are fixed to the
* trusted Electron client registration.
*
* NOTICE:
* better-auth binds the authorization code to a session row. That session must
* still exist when `/oauth2/token` runs, so this helper does not delete the
* session after issuing the code. Cleanup belongs to a later grant that can
* mint codes without a browser session.
*/
export async function issueElectronOidcCode(params: {
auth: AuthInstance
env: Env
userId: string
codeChallenge: string
}): Promise<string> {
const ctx = await params.auth.$context
const session = await ctx.internalAdapter.createSession(params.userId)
if (!session?.token)
throw new Error('Failed to create session for Steam sign-in')
// Throwaway CSRF state: the code is returned in JSON, not via browser redirect.
const state = generateRandomString(32, 'A-Z', 'a-z')
const redirectUri = `${params.env.API_SERVER_URL}/api/auth/oidc/electron-callback`
const scopes = OIDC_SCOPES.join(' ')
const cookieName = ctx.authCookies.sessionToken.name
const signedSession = signSessionCookieValue(session.token, ctx.secret)
const sessionCookie = `${cookieName}=${signedSession}`
const authorizeUrl = new URL('/api/auth/oauth2/authorize', params.env.API_SERVER_URL)
authorizeUrl.searchParams.set('response_type', 'code')
authorizeUrl.searchParams.set('client_id', OIDC_CLIENT_ID_ELECTRON)
authorizeUrl.searchParams.set('redirect_uri', redirectUri)
authorizeUrl.searchParams.set('scope', scopes)
authorizeUrl.searchParams.set('state', state)
authorizeUrl.searchParams.set('code_challenge', params.codeChallenge)
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
authorizeUrl.searchParams.set('resource', params.env.API_SERVER_URL)
const authorizeResponse = await params.auth.handler(new Request(authorizeUrl, {
method: 'GET',
headers: { cookie: sessionCookie },
}))
if (authorizeResponse.status !== 302 && authorizeResponse.status !== 303) {
const body = await authorizeResponse.text()
throw new Error(`OIDC authorize failed (${authorizeResponse.status}): ${body}`)
}
const location = authorizeResponse.headers.get('location')
if (!location)
throw new Error('OIDC authorize missing redirect location')
const callbackUrl = new URL(location, params.env.API_SERVER_URL)
const code = callbackUrl.searchParams.get('code')
if (!code)
throw new Error('OIDC authorize redirect missing authorization code')
return code
}
+122
View File
@@ -0,0 +1,122 @@
import { errorMessageFrom } from '@moeru/std'
const STEAM_PARTNER_API = 'https://partner.steam-api.com'
interface AuthenticateUserTicketResponse {
response?: {
params?: {
result?: string
steamid?: string
}
}
}
interface CheckAppOwnershipResponse {
appownership?: {
ownsapp?: boolean
}
}
interface GetPlayerSummariesResponse {
response?: {
players?: Array<{
personaname?: string
avatarfull?: string
}>
}
}
async function fetchSteamJson<T>(url: URL, label: string): Promise<T> {
let res: Response
try {
res = await fetch(url)
}
catch (error) {
throw new Error(`${label} failed: ${errorMessageFrom(error) ?? 'unknown'}`)
}
if (!res.ok)
throw new Error(`${label} HTTP ${res.status}`)
return await res.json() as T
}
/**
* Verifies a Steam Web API auth ticket (`ISteamUser::GetAuthSessionTicket` /
* `getAuthTicketForWebApi` on the client) and returns the SteamID it proves.
*
* Use when:
* - The desktop app hands the server a session ticket and the server needs
* proof of Steam identity before trusting the claimed SteamID.
*/
export async function authenticateUserTicket(params: {
publisherKey: string
appId: string
ticketHex: string
}): Promise<string> {
const url = new URL('/ISteamUserAuth/AuthenticateUserTicket/v1/', STEAM_PARTNER_API)
url.searchParams.set('key', params.publisherKey)
url.searchParams.set('appid', params.appId)
url.searchParams.set('ticket', params.ticketHex)
url.searchParams.set('identity', 'airi-desktop')
const body = await fetchSteamJson<AuthenticateUserTicketResponse>(url, 'Steam AuthenticateUserTicket')
const result = body.response?.params?.result
const steamId = body.response?.params?.steamid
if (result !== 'OK' || !steamId)
throw new Error(`Steam AuthenticateUserTicket: ${result ?? 'unknown'}`)
return steamId
}
/**
* Checks whether a SteamID owns the given app.
*
* Use when:
* - A verified SteamID (from {@link authenticateUserTicket}) needs an
* anti-fraud check that only the ticket-based desktop path can perform —
* the browser OpenID sign-in has no ticket to check ownership against.
*/
export async function checkAppOwnership(params: {
publisherKey: string
steamId: string
appId: string
}): Promise<boolean> {
const url = new URL('/ISteamUser/CheckAppOwnership/v4/', STEAM_PARTNER_API)
url.searchParams.set('key', params.publisherKey)
url.searchParams.set('steamid', params.steamId)
url.searchParams.set('appid', params.appId)
const body = await fetchSteamJson<CheckAppOwnershipResponse>(url, 'Steam CheckAppOwnership')
return body.appownership?.ownsapp === true
}
/**
* Fetches a SteamID's public display name and avatar.
*
* Returns `null` on any failure (missing player, HTTP error) rather than
* throwing, since profile data is cosmetic and must never block sign-in.
*/
export async function getPlayerSummaries(params: {
publisherKey: string
steamId: string
}): Promise<{ name: string, image: string } | null> {
const url = new URL('/ISteamUser/GetPlayerSummaries/v2/', STEAM_PARTNER_API)
url.searchParams.set('key', params.publisherKey)
url.searchParams.set('steamids', params.steamId)
try {
const body = await fetchSteamJson<GetPlayerSummariesResponse>(url, 'Steam GetPlayerSummaries')
const player = body.response?.players?.[0]
if (!player)
return null
return {
name: player.personaname?.trim() ?? '',
image: player.avatarfull?.trim() ?? '',
}
}
catch {
return null
}
}
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { authenticateUserTicket, checkAppOwnership, getPlayerSummaries } from '../steam-web-api'
describe('authenticateUserTicket', () => {
afterEach(() => vi.restoreAllMocks())
it('returns steamid when Steam API reports success', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
response: { params: { result: 'OK', steamid: '76561198000000000' } },
}),
}))
const steamId = await authenticateUserTicket({
publisherKey: 'test-key',
appId: '3885340',
ticketHex: 'deadbeef',
})
expect(steamId).toBe('76561198000000000')
})
it('throws when result is not OK', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
response: { params: { result: 'InvalidTicket' } },
}),
}))
await expect(authenticateUserTicket({
publisherKey: 'test-key',
appId: '3885340',
ticketHex: 'bad',
})).rejects.toThrow(/InvalidTicket/)
})
})
describe('checkAppOwnership', () => {
afterEach(() => vi.restoreAllMocks())
it('returns true when owns app', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
appownership: { ownsapp: true },
}),
}))
const owns = await checkAppOwnership({
publisherKey: 'test-key',
steamId: '76561198000000000',
appId: '3885340',
})
expect(owns).toBe(true)
})
})
describe('getPlayerSummaries', () => {
afterEach(() => vi.restoreAllMocks())
it('returns name and image from first player', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
response: {
players: [{
steamid: '76561198000000001',
personaname: 'Alice',
avatarfull: 'https://steamcdn-a.akamaihd.net/avatar_full.jpg',
}],
},
}),
}))
const profile = await getPlayerSummaries({
publisherKey: 'test-key',
steamId: '76561198000000001',
})
expect(profile).toEqual({
name: 'Alice',
image: 'https://steamcdn-a.akamaihd.net/avatar_full.jpg',
})
})
it('returns null on HTTP error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({}),
}))
const profile = await getPlayerSummaries({
publisherKey: 'test-key',
steamId: '76561198000000001',
})
expect(profile).toBeNull()
})
it('returns null when players array is empty', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ response: { players: [] } }),
}))
const profile = await getPlayerSummaries({
publisherKey: 'test-key',
steamId: '76561198000000001',
})
expect(profile).toBeNull()
})
})
+7
View File
@@ -15,6 +15,7 @@ import { createForbiddenError } from '../../utils/error'
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 { createAuthUiRoutes } from './ui-routes'
export interface AuthRoutesDeps {
@@ -80,6 +81,12 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
await next()
})
.route('/api/auth', createOIDCTokenAuthRoute(deps))
/**
* Steam Web API ticket sign-in: verifies a session ticket from the
* desktop app, resolves or creates the AIRI user for that SteamID, and
* bridges into a real OIDC authorization code.
*/
.route('/api/auth/steam', createSteamDesktopSignInRoute(deps))
/**
* Electron OIDC callback relay: serves an HTML page that forwards the
* authorization code to the Electron loopback server via JS fetch().
@@ -0,0 +1,172 @@
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { ApiError } from '../../../utils/error'
import { createSteamDesktopSignInRoute } from './desktop-sign-in'
/** Fixed-length S256 challenge fixture (43 base64url chars). */
const CODE_CHALLENGE = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
function createMockDb(userForBanCheck: { banned: boolean, banExpires: Date | null } | undefined = { banned: false, banExpires: null }) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(async () => (userForBanCheck ? [userForBanCheck] : [])),
})),
})),
})),
}
}
function signInBody(overrides?: Record<string, unknown>) {
return JSON.stringify({
ticket: 'deadbeef',
code_challenge: CODE_CHALLENGE,
code_challenge_method: 'S256',
...overrides,
})
}
function buildApp(env: { STEAM_PUBLISHER_KEY: string }, collaborators?: Record<string, unknown>, db?: unknown) {
const route = createSteamDesktopSignInRoute({
auth: { $context: Promise.resolve({ internalAdapter: {} }) } as never,
db: (db ?? createMockDb()) as never,
env: {
API_SERVER_URL: 'http://localhost:3000',
...env,
} as never,
collaborators: {
authenticateUserTicket: vi.fn(async () => '76561198000000000'),
checkAppOwnership: vi.fn(async () => true),
resolveOrCreateSteamUser: vi.fn(async () => ({ userId: 'user-steam-1' })),
issueElectronOidcCode: vi.fn(async () => 'auth-code-1'),
...collaborators,
} as never,
})
return new Hono()
.route('/api/auth/steam', route)
.onError((err, c) => {
if (err instanceof ApiError)
return c.json({ error: err.errorCode }, err.statusCode)
return c.json({ error: 'internal' }, 500)
})
}
describe('post /api/auth/steam/desktop-sign-in', () => {
it('creates a new AIRI user and returns an authorization code on first ticket exchange', async () => {
const resolveOrCreateSteamUser = vi.fn(async () => ({ userId: 'new-steam-user' }))
const issueElectronOidcCode = vi.fn(async () => 'auth-code-1')
const app = buildApp({ STEAM_PUBLISHER_KEY: 'test-key' }, { resolveOrCreateSteamUser, issueElectronOidcCode })
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: signInBody(),
})
expect(res.status).toBe(200)
const body = await res.json() as Record<string, unknown>
expect(body).toEqual({ code: 'auth-code-1' })
expect(resolveOrCreateSteamUser).toHaveBeenCalledWith(expect.anything(), '76561198000000000')
expect(issueElectronOidcCode).toHaveBeenCalledWith(expect.objectContaining({
userId: 'new-steam-user',
codeChallenge: CODE_CHALLENGE,
}))
})
it('resolves the same AIRI user on a repeat ticket exchange (no duplicate account)', async () => {
const resolveOrCreateSteamUser = vi.fn(async () => ({ userId: 'existing-steam-user' }))
const app = buildApp({ STEAM_PUBLISHER_KEY: 'test-key' }, { resolveOrCreateSteamUser })
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: signInBody(),
})
expect(res.status).toBe(200)
expect(resolveOrCreateSteamUser).toHaveBeenCalledTimes(1)
})
it('returns 403 when the resolved user is banned', async () => {
const app = buildApp(
{ STEAM_PUBLISHER_KEY: 'test-key' },
undefined,
createMockDb({ banned: true, banExpires: null }),
)
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: signInBody(),
})
expect(res.status).toBe(403)
})
it('returns 403 STEAM_NO_OWNERSHIP when the account does not own the app', async () => {
const app = buildApp({ STEAM_PUBLISHER_KEY: 'test-key' }, {
checkAppOwnership: vi.fn(async () => false),
})
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: signInBody(),
})
expect(res.status).toBe(403)
const body = await res.json() as Record<string, unknown>
expect(body.error).toBe('STEAM_NO_OWNERSHIP')
})
it('returns 401 STEAM_TICKET_INVALID when ticket verification fails', async () => {
const app = buildApp({ STEAM_PUBLISHER_KEY: 'test-key' }, {
authenticateUserTicket: vi.fn(async () => {
throw new Error('Steam AuthenticateUserTicket: InvalidTicket')
}),
})
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: signInBody(),
})
expect(res.status).toBe(401)
const body = await res.json() as Record<string, unknown>
expect(body.error).toBe('STEAM_TICKET_INVALID')
})
it('returns 503 when Steam publisher key is unset', async () => {
const app = buildApp({ STEAM_PUBLISHER_KEY: '' })
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: signInBody({ ticket: 'abc123' }),
})
expect(res.status).toBe(503)
})
it('returns 400 for invalid ticket body', async () => {
const app = buildApp({ STEAM_PUBLISHER_KEY: 'test-key' })
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: signInBody({ ticket: 'not-hex!' }),
})
expect(res.status).toBe(400)
})
it('returns 400 when code_challenge is missing', async () => {
const app = buildApp({ STEAM_PUBLISHER_KEY: 'test-key' })
const res = await app.request('/api/auth/steam/desktop-sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket: 'deadbeef' }),
})
expect(res.status).toBe(400)
})
})
@@ -0,0 +1,146 @@
import type { AuthInstance } from '../../../libs/auth'
import type { Database } from '../../../libs/db'
import type { Env } from '../../../libs/env'
import type { HonoEnv } from '../../../types/hono'
import { errorMessageFrom } from '@moeru/std'
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import * as v from 'valibot'
import { resolveOrCreateSteamUser } from '../../../libs/auth-plugins/steam'
import { isUserBannedNow } from '../../../libs/request-auth'
import { issueElectronOidcCode } from '../../../libs/steam-oidc-tokens'
import { authenticateUserTicket, checkAppOwnership } from '../../../libs/steam-web-api'
import { user } from '../../../schemas/accounts'
import {
createBadRequestError,
createForbiddenError,
createServiceUnavailableError,
createUnauthorizedError,
} from '../../../utils/error'
/** S256 code_challenge is base64url(SHA-256(...)) without padding — always 43 chars. */
const CodeChallengeSchema = v.pipe(
v.string(),
v.nonEmpty('code_challenge is required'),
v.regex(/^[\w-]{43}$/, 'code_challenge must be a S256 base64url digest'),
)
const DesktopSignInBodySchema = v.object({
ticket: v.pipe(
v.string(),
v.nonEmpty('ticket is required'),
v.regex(/^[0-9a-f]+$/i, 'ticket must be hex-encoded'),
),
code_challenge: CodeChallengeSchema,
code_challenge_method: v.literal('S256'),
})
const STEAM_APP_ID = '3885340'
interface SteamDesktopSignInRouteDeps {
auth: AuthInstance
db: Database
env: Env
collaborators?: Partial<{
authenticateUserTicket: typeof authenticateUserTicket
checkAppOwnership: typeof checkAppOwnership
resolveOrCreateSteamUser: typeof resolveOrCreateSteamUser
issueElectronOidcCode: typeof issueElectronOidcCode
}>
}
/**
* Desktop Steam ticket sign-in: `POST /api/auth/steam/desktop-sign-in`.
*
* Use when:
* - Electron already launched through Steam and holds a Web API session
* ticket. This route verifies the ticket, checks app ownership (anti-fraud
* only the ticket path can do — the browser OpenID plugin has no ticket),
* then resolves or creates the AIRI user for that SteamID via the same
* `internalAdapter`-based policy the OpenID plugin's callback uses, and
* bridges straight into a real OIDC authorization code.
*
* Mechanism:
* - There is no separate "unlinked" outcome: a brand-new SteamID gets a
* brand-new AIRI user immediately (via {@link resolveOrCreateSteamUser}),
* matching how the OpenID plugin already behaves for browser sign-ins.
* Ticket verification + `CheckAppOwnership` already prove Steam identity
* and app ownership server-side, so there is no need to detour through an
* email-enrollment step the way a browser-only sign-in would.
*/
export function createSteamDesktopSignInRoute(deps: SteamDesktopSignInRouteDeps) {
const collaborators = {
authenticateUserTicket,
checkAppOwnership,
resolveOrCreateSteamUser,
issueElectronOidcCode,
...deps.collaborators,
}
return new Hono<HonoEnv>()
.post('/desktop-sign-in', async (c) => {
if (!deps.env.STEAM_PUBLISHER_KEY?.trim())
throw createServiceUnavailableError('Steam sign-in is not configured', 'STEAM_NOT_CONFIGURED')
const parsed = v.safeParse(DesktopSignInBodySchema, await c.req.json().catch(() => null))
if (!parsed.success)
throw createBadRequestError('Invalid request body', 'INVALID_REQUEST')
let steamId: string
try {
steamId = await collaborators.authenticateUserTicket({
publisherKey: deps.env.STEAM_PUBLISHER_KEY,
appId: STEAM_APP_ID,
ticketHex: parsed.output.ticket,
})
}
catch (error) {
throw createUnauthorizedError(
errorMessageFrom(error) ?? 'Steam ticket validation failed',
'STEAM_TICKET_INVALID',
)
}
let ownsApp: boolean
try {
ownsApp = await collaborators.checkAppOwnership({
publisherKey: deps.env.STEAM_PUBLISHER_KEY,
steamId,
appId: STEAM_APP_ID,
})
}
catch (error) {
throw createServiceUnavailableError(
errorMessageFrom(error) ?? 'Steam ownership check failed',
'STEAM_API_UNAVAILABLE',
)
}
if (!ownsApp)
throw createForbiddenError('Steam account does not own this app', 'STEAM_NO_OWNERSHIP')
const ctx = await deps.auth.$context
const { userId } = await collaborators.resolveOrCreateSteamUser(ctx.internalAdapter, steamId)
const [userForBanCheck] = await deps.db
.select({ banned: user.banned, banExpires: user.banExpires })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (userForBanCheck && isUserBannedNow(userForBanCheck))
throw createForbiddenError('This account has been banned')
const code = await collaborators.issueElectronOidcCode({
auth: deps.auth,
env: deps.env,
userId,
codeChallenge: parsed.output.code_challenge,
})
return c.json({ code })
})
}
+4 -4
View File
@@ -29,15 +29,15 @@ export function createBadRequestError(message: string, errorCode = 'BAD_REQUEST'
/**
* Creates an unauthorized error (401)
*/
export function createUnauthorizedError(message = 'Unauthorized', details?: unknown) {
return new ApiError(401, 'UNAUTHORIZED', message, details)
export function createUnauthorizedError(message = 'Unauthorized', errorCode = 'UNAUTHORIZED', details?: unknown) {
return new ApiError(401, errorCode, message, details)
}
/**
* Creates a forbidden error (403)
*/
export function createForbiddenError(message = 'Forbidden', details?: unknown) {
return new ApiError(403, 'FORBIDDEN', message, details)
export function createForbiddenError(message = 'Forbidden', errorCode = 'FORBIDDEN', details?: unknown) {
return new ApiError(403, errorCode, message, details)
}
/**