Files
moeka-project/apps/ui-server-auth/src/modules/sign-in.ts
T
ff7f64ace8 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>
2026-08-05 23:57:55 +08:00

119 lines
4.0 KiB
TypeScript

import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
import { getAuthClient } from './auth-client'
import { extractAuthError } from './auth-fetch'
import { buildAuthUiPath } from './auth-ui-base'
const TRUSTED_ADMIN_REDIRECT_ORIGINS = [
'https://admin.airi.build',
'https://server-dev.airi-server-admin.pages.dev',
]
const TRUSTED_LOCAL_ADMIN_REDIRECT_ORIGIN_PATTERNS = [
/^http:\/\/localhost(:\d+)?$/,
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
/^https:\/\/localhost(:\d+)?$/,
/^https:\/\/127\.0\.0\.1(:\d+)?$/,
]
export interface ServerSignInContext {
callbackURL: string
requestedProvider: string | null
}
export interface SocialSignInRedirectParams {
apiServerUrl: string
provider: OAuthProvider
callbackURL: string
fetchImpl?: typeof fetch
}
export function createServerSignInContext(currentUrl: string, apiServerUrl: string): ServerSignInContext {
const url = new URL(currentUrl)
const oidcParams = new URLSearchParams(url.searchParams)
const requestedProvider = oidcParams.get('provider')
const redirect = oidcParams.get('redirect')
oidcParams.delete('provider')
oidcParams.delete('redirect')
oidcParams.delete('prompt')
oidcParams.delete('api_server_url')
// NOTICE:
// Only synthesize an OIDC authorize callback when the page query genuinely
// looks like an OIDC handoff. Without this guard, a stray `?token=...` —
// e.g. the 24-char password-reset token better-auth appends when it
// redirects through redirectTo (better-auth/dist/api/routes/password.mjs L65, L118)
// back into /auth/sign-in — would synthesize
// `/api/auth/oauth2/authorize?token=...` as the callback. The OIDC zod
// schema then rejects it for missing client_id / response_type
// (oauth-provider/dist/index.mjs L2808-2826) and the user sees a
// VALIDATION_ERROR instead of the sign-in form.
// Removal condition: redirectTo origins are exhaustively scoped so reset /
// verification redirects can never land on /auth/sign-in carrying a `token`.
if (!oidcParams.has('client_id') || !oidcParams.has('response_type')) {
return {
callbackURL: normalizeStandaloneRedirect(url, redirect) ?? '/',
requestedProvider,
}
}
const authorizeUrl = new URL('/api/auth/oauth2/authorize', apiServerUrl)
authorizeUrl.search = oidcParams.toString()
return {
callbackURL: authorizeUrl.toString(),
requestedProvider,
}
}
function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null): string | null {
if (!redirect)
return null
const trustedAdminRedirect = normalizeTrustedAdminRedirect(redirect)
if (trustedAdminRedirect)
return trustedAdminRedirect
if (!redirect.startsWith('/') || redirect.startsWith('//'))
return null
if (redirect.startsWith('/admin'))
return `${currentUrl.origin}${redirect}`
return `${currentUrl.origin}${buildAuthUiPath(redirect)}`
}
function normalizeTrustedAdminRedirect(redirect: string): string | null {
try {
const url = new URL(redirect)
if (TRUSTED_ADMIN_REDIRECT_ORIGINS.includes(url.origin))
return url.toString()
if (TRUSTED_LOCAL_ADMIN_REDIRECT_ORIGIN_PATTERNS.some(pattern => pattern.test(url.origin)))
return url.toString()
return null
}
catch {
return null
}
}
export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise<string> {
const client = getAuthClient({ apiServerUrl: params.apiServerUrl, fetchImpl: params.fetchImpl })
// Steam is OpenID 2.0, not OAuth2 — the server steam plugin exposes
// `/sign-in/steam`, surfaced here as the typed `signIn.steam` action.
// Other providers use the standard `/sign-in/social`.
const result = params.provider === 'steam'
? await client.signIn.steam({ callbackURL: params.callbackURL, disableRedirect: true })
: await client.signIn.social({ provider: params.provider, callbackURL: params.callbackURL, disableRedirect: true })
const url = result.data?.url
if (typeof url === 'string')
return url
throw new Error(extractAuthError(result.data ?? result.error) ?? 'Unexpected response')
}