mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user