feat(server): add Steam OpenID sign-in and account linking plugin (#2226)

## Summary

Adds a self-contained better-auth plugin
(`server/apps/api/src/libs/auth-plugins/steam.ts`) implementing Steam
OpenID 2.0 sign-in, account linking, and callback verification via "dumb
mode".

Steam's web login is OpenID 2.0, not OAuth2/OIDC, so it cannot be
registered as a `socialProviders` entry, and better-auth has no plugin
hook for extending its OAuth2 endpoints with a non-OAuth2 protocol. The
plugin therefore adds the endpoints Steam's protocol needs: `POST
/sign-in/steam`, `POST /link/steam`, and `GET /steam/callback`.

- Callback verification uses OpenID "dumb mode"
(`openid.mode=check_authentication`): one extra round trip to Steam
instead of managing RSA association state.
- New sign-ups get a placeholder `<steamid64>@steam.placeholder.local`
with `emailVerified: true`, mirroring Apple Sign In's
`<sub>@apple.placeholder.local`.
- The plugin's request/query schemas use Zod; a `// NOTICE:` documents
that better-auth's OpenAPI generator is Zod-native. Steam verification
uses `ofetch`.
- Wires Steam into `apps/ui-server-auth` sign-in and profile "Connected
accounts", plus the shared `OAuthProvider` / `defaultSignInProviders` in
`packages/stage-ui`.
- Linking routes through `/link/steam` via the client's `$fetch`;
unlinking needs no special-casing (`/unlink-account` already takes a
free-form `providerId`).

No Steam Web API key is required for this browser-based flow.

We intentionally do not depend on community Steam packages (e.g.
`better-auth-steam`) or the still-open upstream draft
([better-auth#4877](https://github.com/better-auth/better-auth/pull/4877)).
Steam never returns an email, and we need sign-up that does not ask the
user for one plus first-class account linking; the available options
either require an email at sign-in, lack linking, or are abandoned /
blocked — shipping a small in-tree plugin is the safer auth dependency
for this requirement.

## Test plan

- [x] `pnpm exec vitest run
server/apps/api/src/libs/auth-plugins/steam.test.ts` — 6/6 passing
- [x] `pnpm -F @proj-airi/ui-server-auth exec vitest run` — 32/32
passing
- [x] `pnpm -F @proj-airi/stage-ui exec vitest run
src/libs/steam-auth-client.test.ts
src/composables/use-linked-accounts.test.ts` — 5/5 passing
- [x] `pnpm -F @proj-airi/api-server typecheck`
- [x] `pnpm -F @proj-airi/ui-server-auth typecheck`
- [x] `pnpm -F @proj-airi/stage-ui typecheck`

## Follow-ups

- Desktop Steam ticket sign-in (top of this stack): silent startup
ticket exchange for Steam builds; the server resolves or creates the
AIRI user for the verified SteamID before issuing an OIDC code.
- Steam persona name/avatar via `GetPlayerSummaries` inside the plugin,
if display names beyond `Steam User <id>` are wanted.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lulu
2026-08-05 23:57:55 +08:00
committed by GitHub
co-authored by Cursor autofix-ci[bot]
parent b35a63b23e
commit ff7f64ace8
21 changed files with 1054 additions and 1759 deletions
@@ -0,0 +1,260 @@
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../mock-db'
import { steam } from './steam'
import * as schema from '../../schemas'
/** Test fixture: arbitrary valid-format SteamID64 used in fake OpenID callbacks. */
const STEAM_ID = '76561198012345678'
/**
* Merges `Set-Cookie` headers from one or more responses into a single
* `Cookie` header value, later sources overriding earlier ones by name.
*
* Tests call `auth.handler` directly with no shared cookie jar, so they
* must forward cookies themselves; real browsers carry them automatically
* across the Steam round trip since they're same-site.
*
* `Headers.get('set-cookie')` comma-joins repeated headers, which breaks on
* cookies whose own attributes contain commas (e.g. `Expires=Thu, 01...`);
* `getSetCookie()` returns each header value un-mangled. Merging by name
* (not just concatenating) matters here because the callback response both
* clears the spent `better-auth.state` cookie (empty value) and, on a later
* `/link/steam` call, sets a *new* `better-auth.state` for the next round
* trip — a naive concatenation would send both, and cookie-header parsers
* are free to keep whichever duplicate they see first.
*/
function forwardableCookieHeader(...headerSources: Headers[]): string {
const cookies = new Map<string, string>()
for (const headers of headerSources) {
for (const setCookie of headers.getSetCookie()) {
const [nameValue] = setCookie.split(';')
const [name, value] = nameValue.split('=')
cookies.set(name, value)
}
}
return Array.from(cookies.entries()).map(([name, value]) => `${name}=${value}`).join('; ')
}
/** Builds a fake Steam OpenID `id_res` callback query, as if Steam redirected the browser here. */
function buildCallbackQuery(state: string, steamId = STEAM_ID): string {
const params = new URLSearchParams({
state,
'openid.mode': 'id_res',
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.op_endpoint': 'https://steamcommunity.com/openid/login',
'openid.claimed_id': `https://steamcommunity.com/openid/id/${steamId}`,
'openid.identity': `https://steamcommunity.com/openid/id/${steamId}`,
'openid.return_to': 'http://localhost/api/auth/steam/callback',
'openid.response_nonce': '2026-07-31T00:00:00Zxxxxx',
'openid.assoc_handle': 'test-handle',
'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
'openid.sig': 'test-signature',
})
return params.toString()
}
async function createTestAuth() {
const db = await mockDB(schema)
return betterAuth({
database: drizzleAdapter(db, { provider: 'pg', schema }),
secret: 'test-secret',
baseURL: 'http://localhost',
plugins: [steam()],
})
}
describe('steam auth plugin', () => {
let auth: Awaited<ReturnType<typeof createTestAuth>>
beforeAll(async () => {
auth = await createTestAuth()
})
afterEach(() => vi.unstubAllGlobals())
// NOTICE:
// We mock the module-global `fetch` for Steam's `check_authentication`
// dumb-mode verification POST instead of hitting the real
// steamcommunity.com endpoint, keeping this test hermetic and fast.
// Root cause of picking dumb mode over signature verification: see the
// plugin's own doc comment in ./steam.ts.
function mockSteamVerification(isValid: boolean) {
vi.stubGlobal('fetch', vi.fn(async (url: string | URL) => {
if (url.toString() === 'https://steamcommunity.com/openid/login') {
return new Response(`ns:http://specs.openid.net/auth/2.0\nis_valid:${isValid}`, { status: 200 })
}
throw new Error(`Unexpected fetch to ${url}`)
}))
}
it('redirects to the Steam OpenID login URL on sign-in start', async () => {
const response = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
const url = new URL(response.response.url)
expect(url.origin + url.pathname).toBe('https://steamcommunity.com/openid/login')
expect(url.searchParams.get('openid.mode')).toBe('checkid_setup')
expect(url.searchParams.get('openid.realm')).toBe('http://localhost')
expect(url.searchParams.get('openid.return_to')).toContain('/steam/callback?state=')
})
it('skips the automatic redirect when disableRedirect is set', async () => {
const { response } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile', disableRedirect: true },
returnHeaders: true,
})
expect(response.redirect).toBe(false)
})
it('creates a user with a placeholder email on first sign-in and reuses the same account on later sign-ins', async () => {
mockSteamVerification(true)
const context = await auth.$context
const { response: startResponse, headers: startHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
const returnToState = new URL(new URL(startResponse.url).searchParams.get('openid.return_to')!).searchParams.get('state')!
const callbackResponse = await auth.handler(
new Request(`http://localhost/api/auth/steam/callback?${buildCallbackQuery(returnToState)}`, {
headers: { cookie: forwardableCookieHeader(startHeaders) },
}),
)
expect(callbackResponse.status).toBe(302)
expect(callbackResponse.headers.get('location')).toBe('http://localhost/ui/profile')
expect(callbackResponse.headers.get('set-cookie')).toMatch(/better-auth\.session_token=/)
const account = await context.internalAdapter.findAccountByProviderId(STEAM_ID, 'steam')
expect(account).not.toBeNull()
const user = await context.internalAdapter.findUserById(account!.userId)
expect(user?.email).toBe(`${STEAM_ID}@steam.placeholder.local`)
expect(user?.emailVerified).toBe(true)
const { response: secondStart, headers: secondStartHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
const secondState = new URL(new URL(secondStart.url).searchParams.get('openid.return_to')!).searchParams.get('state')!
await auth.handler(new Request(`http://localhost/api/auth/steam/callback?${buildCallbackQuery(secondState)}`, {
headers: { cookie: forwardableCookieHeader(secondStartHeaders) },
}))
const accountAfterSecondSignIn = await context.internalAdapter.findAccountByProviderId(STEAM_ID, 'steam')
expect(accountAfterSecondSignIn?.userId).toBe(account?.userId)
})
it('redirects to an error URL when Steam verification fails', async () => {
mockSteamVerification(false)
const { response: startResponse, headers: startHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
const returnToState = new URL(new URL(startResponse.url).searchParams.get('openid.return_to')!).searchParams.get('state')!
const otherSteamId = '76561198099999999'
const callbackResponse = await auth.handler(
new Request(`http://localhost/api/auth/steam/callback?${buildCallbackQuery(returnToState, otherSteamId)}`, {
headers: { cookie: forwardableCookieHeader(startHeaders) },
}),
)
expect(callbackResponse.status).toBe(302)
expect(callbackResponse.headers.get('location')).toContain('error=steam_openid_verification_failed')
})
it('links a second Steam account to the already-signed-in user instead of creating a new one', async () => {
mockSteamVerification(true)
const context = await auth.$context
// Sign in as a fresh user via Steam first, to get a session cookie to link against.
const primarySteamId = '76561198011111111'
const { response: primaryStart, headers: primaryStartHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
const primaryState = new URL(new URL(primaryStart.url).searchParams.get('openid.return_to')!).searchParams.get('state')!
const primaryCallback = await auth.handler(new Request(
`http://localhost/api/auth/steam/callback?${buildCallbackQuery(primaryState, primarySteamId)}`,
{ headers: { cookie: forwardableCookieHeader(primaryStartHeaders) } },
))
const sessionCookie = forwardableCookieHeader(primaryCallback.headers)
const primaryUserId = (await context.internalAdapter.findAccountByProviderId(primarySteamId, 'steam'))!.userId
// Now link a second Steam account to that same session.
const secondSteamId = '76561198022222222'
const { response: linkStart, headers: linkStartHeaders } = await auth.api.linkSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
headers: { cookie: sessionCookie },
returnHeaders: true,
})
const linkState = new URL(new URL(linkStart.url).searchParams.get('openid.return_to')!).searchParams.get('state')!
const linkCallback = await auth.handler(new Request(
`http://localhost/api/auth/steam/callback?${buildCallbackQuery(linkState, secondSteamId)}`,
{ headers: { cookie: forwardableCookieHeader(primaryCallback.headers, linkStartHeaders) } },
))
expect(linkCallback.status).toBe(302)
expect(linkCallback.headers.get('location')).toBe('http://localhost/ui/profile')
const linkedAccount = await context.internalAdapter.findAccountByProviderId(secondSteamId, 'steam')
expect(linkedAccount?.userId).toBe(primaryUserId)
})
it('refuses to link a Steam account that already belongs to a different user', async () => {
mockSteamVerification(true)
const context = await auth.$context
const claimedSteamId = '76561198033333333'
const claimingUserId = (await context.internalAdapter.createUser({
email: 'someone-else@example.com',
emailVerified: true,
name: 'Someone Else',
})).id
await context.internalAdapter.linkAccount({
userId: claimingUserId,
providerId: 'steam',
accountId: claimedSteamId,
})
// A second, unrelated user tries to link the same Steam account.
const { response: primaryStart, headers: primaryStartHeaders } = await auth.api.signInSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
returnHeaders: true,
})
const primarySteamId = '76561198044444444'
const primaryState = new URL(new URL(primaryStart.url).searchParams.get('openid.return_to')!).searchParams.get('state')!
const primaryCallback = await auth.handler(new Request(
`http://localhost/api/auth/steam/callback?${buildCallbackQuery(primaryState, primarySteamId)}`,
{ headers: { cookie: forwardableCookieHeader(primaryStartHeaders) } },
))
const sessionCookie = forwardableCookieHeader(primaryCallback.headers)
const { response: linkStart, headers: linkStartHeaders } = await auth.api.linkSteam({
body: { callbackURL: 'http://localhost/ui/profile' },
headers: { cookie: sessionCookie },
returnHeaders: true,
})
const linkState = new URL(new URL(linkStart.url).searchParams.get('openid.return_to')!).searchParams.get('state')!
const linkCallback = await auth.handler(new Request(
`http://localhost/api/auth/steam/callback?${buildCallbackQuery(linkState, claimedSteamId)}`,
{ headers: { cookie: forwardableCookieHeader(primaryCallback.headers, linkStartHeaders) } },
))
expect(linkCallback.status).toBe(302)
expect(linkCallback.headers.get('location')).toContain('error=account_already_linked_to_different_user')
const stillClaimingUser = await context.internalAdapter.findAccountByProviderId(claimedSteamId, 'steam')
expect(stillClaimingUser?.userId).toBe(claimingUserId)
})
})
@@ -0,0 +1,236 @@
import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
import { setSessionCookie } from 'better-auth/cookies'
import { generateState, parseState } from 'better-auth/oauth2'
import { ofetch } from 'ofetch'
import * as z from 'zod'
const STEAM_OPENID_ENDPOINT = 'https://steamcommunity.com/openid/login'
const STEAM_OPENID_NS = 'http://specs.openid.net/auth/2.0'
const STEAM_OPENID_IDENTIFIER_SELECT = 'http://specs.openid.net/auth/2.0/identifier_select'
/** Matches `https://steamcommunity.com/openid/id/<steamid64>`. */
const STEAM_CLAIMED_ID_PATTERN = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/
// NOTICE:
// Why Zod instead of the repo-default Valibot: better-auth's endpoint API and
// OpenAPI generator are Zod-native. The generator introspects
// `instanceof z.ZodObject` on `body`/`query` to emit request/query schemas
// (node_modules/better-auth/dist/plugins/open-api/generator.mjs), so Valibot
// 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.
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(),
disableRedirect: z.boolean().optional(),
})
const CallbackQuerySchema = z.looseObject({
'state': z.string().optional(),
'openid.mode': z.string().optional(),
})
/**
* Steam OpenID 2.0 sign-in / account-linking plugin.
*
* Steam's web login is OpenID 2.0, not OAuth2/OIDC, so it can't be a
* `socialProviders` entry — this plugin adds the endpoints its protocol
* needs: `POST /sign-in/steam`, `POST /link/steam`, `GET /steam/callback`.
*
* Identity model:
* - Steam never exposes an email address. New sign-ups get a placeholder
* `<steamid64>@steam.placeholder.local` (mirrors Apple's
* `<sub>@apple.placeholder.local`) with `emailVerified: true` — the
* placeholder can never receive mail, so verification is meaningless and
* would otherwise permanently block sign-in.
*
* Mechanism:
* - Both start endpoints build the same `checkid_setup` redirect URL,
* differing only in whether `generateState` records a `link: { userId,
* email }` (link requires an active session via `sessionMiddleware`).
* Reusing `generateState`/`parseState` gets the same verification-table-
* backed CSRF state storage the built-in OAuth2 plugins use, without
* re-implementing it.
* - `GET /steam/callback` verifies via OpenID "dumb mode"
* (`openid.mode=check_authentication`, POSTed back to Steam) instead of
* validating the RSA signature ourselves — no association/session state
* to manage, at the cost of one extra HTTP round trip per login.
*/
export function steam() {
function buildOpenIdRedirectURL(baseURL: string, state: string): string {
const returnTo = new URL(`${baseURL}/steam/callback`)
returnTo.searchParams.set('state', state)
const redirectURL = new URL(STEAM_OPENID_ENDPOINT)
redirectURL.searchParams.set('openid.ns', STEAM_OPENID_NS)
redirectURL.searchParams.set('openid.mode', 'checkid_setup')
redirectURL.searchParams.set('openid.return_to', returnTo.toString())
redirectURL.searchParams.set('openid.realm', new URL(baseURL).origin)
redirectURL.searchParams.set('openid.identity', STEAM_OPENID_IDENTIFIER_SELECT)
redirectURL.searchParams.set('openid.claimed_id', STEAM_OPENID_IDENTIFIER_SELECT)
return redirectURL.toString()
}
/**
* Verifies a Steam OpenID callback via "dumb mode": relay every
* `openid.*` field Steam sent us back to Steam with `mode` swapped to
* `check_authentication`, and trust its `is_valid:true` verdict instead of
* checking the RSA signature ourselves.
*/
async function verifyOpenIdCallback(query: Record<string, string>): Promise<boolean> {
const verifyParams = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (key.startsWith('openid.'))
verifyParams.set(key, value)
}
verifyParams.set('openid.mode', 'check_authentication')
try {
const body = await ofetch<string, 'text'>(STEAM_OPENID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: verifyParams.toString(),
responseType: 'text',
})
return body.split('\n').some(line => line.trim() === 'is_valid:true')
}
catch {
// Steam unreachable or non-2xx: the callback cannot proceed anyway, so
// collapse it into a verification failure and let the caller's error
// redirect handle it instead of surfacing a second exception.
return false
}
}
const signInSteam = createAuthEndpoint('/sign-in/steam', {
method: 'POST',
body: SignInBodySchema,
metadata: {
openapi: {
description: 'Start Steam OpenID sign-in',
responses: {
200: {
description: 'Redirect URL to Steam OpenID login',
content: { 'application/json': { schema: { type: 'object', properties: { url: { type: 'string' }, redirect: { type: 'boolean' } } } } },
},
},
},
},
}, async (ctx) => {
const { state } = await generateState(ctx, undefined, undefined)
return ctx.json({
url: buildOpenIdRedirectURL(ctx.context.baseURL, state),
redirect: !ctx.body.disableRedirect,
})
})
const linkSteam = createAuthEndpoint('/link/steam', {
method: 'POST',
body: SignInBodySchema,
use: [sessionMiddleware],
metadata: {
openapi: {
description: 'Link the current user to a Steam account',
responses: {
200: {
description: 'Redirect URL to Steam OpenID login',
content: { 'application/json': { schema: { type: 'object', properties: { url: { type: 'string' }, redirect: { type: 'boolean' } } } } },
},
},
},
},
}, async (ctx) => {
const session = ctx.context.session
const { state } = await generateState(ctx, { userId: session.user.id, email: session.user.email }, undefined)
return ctx.json({
url: buildOpenIdRedirectURL(ctx.context.baseURL, state),
redirect: !ctx.body.disableRedirect,
})
})
const steamCallback = createAuthEndpoint('/steam/callback', {
method: 'GET',
query: CallbackQuerySchema,
metadata: {
openapi: {
description: 'Steam OpenID callback',
responses: { 200: { description: 'Redirects to callbackURL or errorURL' } },
},
},
}, async (ctx) => {
const parsedState = await parseState(ctx)
const callbackURL = parsedState.callbackURL
// `parseState` always backfills this with `${baseURL}/error` when the
// sign-in/link request didn't supply one (better-auth/dist/oauth2/state.mjs);
// the `?` in its type only reflects the pre-backfill shape.
const errorURL = parsedState.errorURL ?? `${ctx.context.baseURL}/error`
const link = parsedState.link
function redirectOnError(error: string): never {
const url = errorURL.includes('?') ? `${errorURL}&error=${error}` : `${errorURL}?error=${error}`
throw ctx.redirect(url)
}
if (ctx.query['openid.mode'] !== 'id_res')
return redirectOnError('steam_openid_denied')
const isValid = await verifyOpenIdCallback(ctx.query as Record<string, string>)
if (!isValid)
return redirectOnError('steam_openid_verification_failed')
const claimedId = ctx.query['openid.claimed_id'] as string | undefined
const steamId = claimedId ? (STEAM_CLAIMED_ID_PATTERN.exec(claimedId)?.[1] ?? null) : null
if (!steamId)
return redirectOnError('steam_claimed_id_missing')
const existingAccount = await ctx.context.internalAdapter.findAccountByProviderId(steamId, 'steam')
if (link) {
if (existingAccount && existingAccount.userId !== link.userId)
return redirectOnError('account_already_linked_to_different_user')
if (!existingAccount) {
await ctx.context.internalAdapter.linkAccount({
userId: link.userId,
providerId: 'steam',
accountId: steamId,
})
}
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 user = await ctx.context.internalAdapter.findUserById(userId)
if (!user)
return redirectOnError('steam_user_not_found')
const newSession = await ctx.context.internalAdapter.createSession(userId)
await setSessionCookie(ctx, { session: newSession, user })
throw ctx.redirect(callbackURL)
})
return {
id: 'steam',
endpoints: {
signInSteam,
linkSteam,
steamCallback,
},
}
}
+5
View File
@@ -22,6 +22,7 @@ import { importPKCS8, SignJWT } from 'jose'
import { ApiError } from '../utils/error'
import { getAuthTrustedOrigins, getTrustedOrigin } from '../utils/origin'
import { oidcJwtBearer } from './auth-plugins/oidc-jwt-bearer'
import { steam } from './auth-plugins/steam'
import * as authSchema from '../schemas/accounts'
@@ -476,6 +477,10 @@ export function createAuth(
// already handles. See libs/auth-plugins/oidc-jwt-bearer.ts for the
// architectural mismatch this paves over.
oidcJwtBearer(env),
// Steam's web login is OpenID 2.0, not OAuth2/OIDC, so it can't be a
// `socialProviders` entry — see libs/auth-plugins/steam.ts for why this
// needs to be its own plugin.
steam(),
magicLink({
// NOTICE: better-auth's magic-link callback receives a server-side
// verification URL ({baseURL}/magic-link/verify?token=...&callbackURL=...).