mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
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:
co-authored by
Cursor
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent
b35a63b23e
commit
ff7f64ace8
@@ -39,7 +39,7 @@ describe('createWidgetsService', () => {
|
||||
const widgetsManager = createWidgetsManager()
|
||||
const window = createWindow(1)
|
||||
createWidgetsService({
|
||||
context: context as Parameters<typeof createWidgetsService>[0]['context'],
|
||||
context: context as never,
|
||||
widgetsManager,
|
||||
window,
|
||||
})
|
||||
@@ -70,7 +70,7 @@ describe('createWidgetsService', () => {
|
||||
const widgetsManager = createWidgetsManager()
|
||||
const window = createWindow(1)
|
||||
createWidgetsService({
|
||||
context: context as Parameters<typeof createWidgetsService>[0]['context'],
|
||||
context: context as never,
|
||||
widgetsManager,
|
||||
window,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import type { OauthCallbackFailureStage } from '@proj-airi/stage-ui/composables'
|
||||
|
||||
/** Login/signup credential kinds shown on the sign-in page. */
|
||||
export type AuthMethod = 'email' | 'github' | 'google'
|
||||
export type AuthMethod = 'email' | 'github' | 'google' | 'steam'
|
||||
|
||||
interface CaptureOptions {
|
||||
/**
|
||||
|
||||
@@ -1,34 +1,24 @@
|
||||
/**
|
||||
* Better-auth client factory for the auth-only SPA (`apps/ui-server-auth`).
|
||||
*
|
||||
* Use when:
|
||||
* - Calling any `/api/auth/*` endpoint from the auth UI (profile read/write,
|
||||
* sign-in / sign-up, password reset, linked accounts management). Lets us
|
||||
* reuse better-auth's typed client surface instead of re-deriving response
|
||||
* shapes from `unknown` JSON in N hand-written wrappers.
|
||||
* Separate from the stage-ui singleton because that client is Bearer-only:
|
||||
* it omits cookies and injects the auth-store token on every request, which
|
||||
* makes no sense on the page the session cookie was just set on. This client
|
||||
* uses better-auth's cookie defaults (`credentials: 'include'`) instead.
|
||||
*
|
||||
* Why a separate factory (vs. importing the singleton in
|
||||
* `packages/stage-ui/src/libs/auth.ts`):
|
||||
* - Stage-UI's client is configured for **Bearer-only** access (`credentials:
|
||||
* 'omit'` so cookies don't tag along with OIDC JWTs). It also injects a
|
||||
* Bearer token from the auth store on every request — nonsense in this
|
||||
* app, since the auth UI is the page the cookie was *just* set on.
|
||||
* - This client uses the better-auth defaults (cookies via
|
||||
* `credentials: 'include'`) and skips the Bearer header. That matches
|
||||
* what the auth UI actually has at hand.
|
||||
*
|
||||
* Test seam:
|
||||
* - Pass `fetchImpl` to substitute `globalThis.fetch`. Better-auth wires it
|
||||
* as `customFetchImpl` (see node_modules/better-auth/dist/client/config.mjs
|
||||
* L+: the spread of `restOfFetchOptions` happens after the default, so a
|
||||
* user-supplied value wins). Production callers omit `fetchImpl` and we
|
||||
* memoise per `apiServerUrl` so we don't rebuild on every render.
|
||||
* Test seam: pass `fetchImpl` to substitute `globalThis.fetch` (wired as
|
||||
* `customFetchImpl`; see node_modules/better-auth/dist/client/config.mjs L+
|
||||
* — the `restOfFetchOptions` spread happens after the default, so a
|
||||
* user-supplied value wins). With `fetchImpl` we don't memoise, so tests
|
||||
* can't leak state between cases; production callers memoise per
|
||||
* `apiServerUrl`.
|
||||
*
|
||||
* Removal condition: better-auth ships a hosted typed client for OIDC IdP
|
||||
* setups where one process is both IdP and resource server. Until then,
|
||||
* one factory per credential mode is the cleanest contract.
|
||||
*/
|
||||
|
||||
import { steamClient } from '@proj-airi/stage-ui/libs/steam-auth-client'
|
||||
import { createAuthClient } from 'better-auth/vue'
|
||||
|
||||
export interface AuthClientArgs {
|
||||
@@ -40,39 +30,31 @@ export interface AuthClientArgs {
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
const cache = new Map<string, ReturnType<typeof createAuthClient>>()
|
||||
type AuthClient = ReturnType<typeof createAuthClient<{
|
||||
baseURL: string
|
||||
plugins: ReturnType<typeof steamClient>[]
|
||||
}>>
|
||||
|
||||
const clientCache = new Map<string, AuthClient>()
|
||||
|
||||
/**
|
||||
* Build (or reuse) a better-auth client pointed at the given server.
|
||||
*
|
||||
* Use when:
|
||||
* - Any module needs to call `/api/auth/*` from the auth UI.
|
||||
*
|
||||
* Expects:
|
||||
* - `apiServerUrl` is a fully-qualified origin (e.g. `https://api.airi.test`
|
||||
* or `http://localhost:3000`). Trailing slash optional; better-auth
|
||||
* normalises.
|
||||
*
|
||||
* Returns:
|
||||
* - A typed client whose methods (`getSession`, `updateUser`, `listAccounts`,
|
||||
* etc.) match the better-auth endpoint surface. Tokens / cookies handled
|
||||
* via `credentials: 'include'` defaults.
|
||||
* Cookie-credentialed better-auth client for the auth UI, with the Steam
|
||||
* plugin wired in (`linkSteam` / `signIn.steam`). Unlike the Bearer-only
|
||||
* stage-ui singleton, this client carries the session cookie.
|
||||
*/
|
||||
export function getAuthClient(args: AuthClientArgs): ReturnType<typeof createAuthClient> {
|
||||
export function getAuthClient(args: AuthClientArgs): AuthClient {
|
||||
if (args.fetchImpl) {
|
||||
// Tests: never cache, never share. The injected fetchImpl is the whole
|
||||
// point of the call.
|
||||
return createAuthClient({
|
||||
baseURL: args.apiServerUrl,
|
||||
plugins: [steamClient()],
|
||||
fetchOptions: { customFetchImpl: args.fetchImpl },
|
||||
})
|
||||
}
|
||||
|
||||
const cached = cache.get(args.apiServerUrl)
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
const client = createAuthClient({ baseURL: args.apiServerUrl })
|
||||
cache.set(args.apiServerUrl, client)
|
||||
const client = clientCache.get(args.apiServerUrl) ?? createAuthClient({
|
||||
baseURL: args.apiServerUrl,
|
||||
plugins: [steamClient()],
|
||||
})
|
||||
clientCache.set(args.apiServerUrl, client)
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -119,20 +119,35 @@ describe('ui-server-auth sign-in flow helpers', () => {
|
||||
})).resolves.toBe('https://accounts.example.test/oauth/google')
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://api.airi.test/api/auth/sign-in/social',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
}),
|
||||
)
|
||||
|
||||
const init = fetchImpl.mock.calls[0]?.[1]
|
||||
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
const [url, init] = fetchImpl.mock.calls[0] ?? []
|
||||
expect(String(url)).toBe('https://api.airi.test/api/auth/sign-in/social')
|
||||
expect((init as RequestInit).method).toBe('POST')
|
||||
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
|
||||
provider: 'google',
|
||||
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
|
||||
disableRedirect: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('posts only the callback URL (no provider field) to the Steam sign-in endpoint', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => {
|
||||
return new Response(JSON.stringify({ url: 'https://steamcommunity.com/openid/login?...', redirect: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(requestSocialSignInRedirect({
|
||||
apiServerUrl: 'https://api.airi.test',
|
||||
provider: 'steam',
|
||||
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
|
||||
fetchImpl,
|
||||
})).resolves.toBe('https://steamcommunity.com/openid/login?...')
|
||||
|
||||
const [url, init] = fetchImpl.mock.calls[0] ?? []
|
||||
expect(String(url)).toBe('https://api.airi.test/api/auth/sign-in/steam')
|
||||
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
|
||||
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web',
|
||||
disableRedirect: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -100,27 +101,18 @@ function normalizeTrustedAdminRedirect(redirect: string): string | null {
|
||||
}
|
||||
|
||||
export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise<string> {
|
||||
const fetchImpl = params.fetchImpl ?? fetch
|
||||
const endpoint = new URL('/api/auth/sign-in/social', params.apiServerUrl)
|
||||
const response = await fetchImpl(endpoint.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: params.provider,
|
||||
callbackURL: params.callbackURL,
|
||||
}),
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
const client = getAuthClient({ apiServerUrl: params.apiServerUrl, fetchImpl: params.fetchImpl })
|
||||
|
||||
if (response.type === 'opaqueredirect' || response.status === 302) {
|
||||
return response.headers.get('location') || '/'
|
||||
}
|
||||
// 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 data = await response.json() as { url?: unknown }
|
||||
const url = result.data?.url
|
||||
if (typeof url === 'string')
|
||||
return url
|
||||
|
||||
if (typeof data.url === 'string')
|
||||
return data.url
|
||||
|
||||
throw new Error(extractAuthError(data) ?? 'Unexpected response')
|
||||
throw new Error(extractAuthError(result.data ?? result.error) ?? 'Unexpected response')
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ function handleUnlinkProvider(providerId: string) {
|
||||
return unlinkLinkedProvider(providerId, providerName)
|
||||
}
|
||||
|
||||
function handleLinkProvider(providerId: 'github' | 'google') {
|
||||
function handleLinkProvider(providerId: 'github' | 'google' | 'steam') {
|
||||
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
|
||||
return linkLinkedProvider(providerId, providerName)
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ async function handleEmailSignUp(event: Event) {
|
||||
v-for="provider in defaultSignInProviders"
|
||||
:key="provider.id"
|
||||
:class="['w-full', 'py-2', 'flex', 'items-center', 'justify-center']"
|
||||
:icon="provider.id === 'google' ? 'i-simple-icons-google' : provider.id === 'github' ? 'i-simple-icons-github' : undefined"
|
||||
:icon="provider.icon"
|
||||
:loading="pendingProvider === provider.id"
|
||||
@click="handleProviderSelect(provider.id)"
|
||||
>
|
||||
|
||||
@@ -207,7 +207,7 @@ function handleUnlinkProvider(providerId: string) {
|
||||
return unlinkLinkedProvider(providerId, providerName)
|
||||
}
|
||||
|
||||
function handleLinkProvider(providerId: 'github' | 'google') {
|
||||
function handleLinkProvider(providerId: 'github' | 'google' | 'steam') {
|
||||
linkedAccountsRouteErrorKey.value = null
|
||||
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
|
||||
return linkLinkedProvider(providerId, providerName)
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-fetch/fetch": "catalog:",
|
||||
"@date-fns/utc": "catalog:",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@huggingface/transformers": "catalog:",
|
||||
|
||||
@@ -17,4 +17,9 @@ export const defaultSignInProviders = [
|
||||
name: 'GitHub',
|
||||
icon: 'i-simple-icons-github',
|
||||
},
|
||||
{
|
||||
id: 'steam',
|
||||
name: 'Steam',
|
||||
icon: 'i-simple-icons-steam',
|
||||
},
|
||||
] satisfies SignInProviderDefinition[]
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import type { LinkedAccountsClient } from './use-linked-accounts'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSSRApp, ref } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
|
||||
import { useLinkedAccounts } from './use-linked-accounts'
|
||||
|
||||
function fakeLinkedAccountsClient(overrides: Partial<LinkedAccountsClient> = {}): LinkedAccountsClient {
|
||||
return {
|
||||
listAccounts: vi.fn(async () => ({ data: [], error: null })),
|
||||
unlinkAccount: vi.fn(async () => ({ data: null, error: null })),
|
||||
linkSocial: vi.fn(async () => ({ data: null, error: null })),
|
||||
linkSteam: vi.fn(async () => ({ data: null, error: null })),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useLinkedAccounts', () => {
|
||||
it('passes the profile page URL as the OAuth link error callback URL', async () => {
|
||||
const linkSocial = vi.fn(async () => ({
|
||||
@@ -21,6 +33,7 @@ describe('useLinkedAccounts', () => {
|
||||
listAccounts: vi.fn(async () => ({ data: [], error: null })),
|
||||
unlinkAccount: vi.fn(async () => ({ data: null, error: null })),
|
||||
linkSocial,
|
||||
linkSteam: vi.fn(async () => ({ data: null, error: null })),
|
||||
},
|
||||
isAuthenticated: ref(false),
|
||||
describeError: () => '',
|
||||
@@ -79,6 +92,7 @@ describe('useLinkedAccounts', () => {
|
||||
})),
|
||||
unlinkAccount,
|
||||
linkSocial,
|
||||
linkSteam: vi.fn(async () => ({ data: null, error: null })),
|
||||
},
|
||||
isAuthenticated: ref(false),
|
||||
describeError: () => 'boom',
|
||||
@@ -124,3 +138,72 @@ describe('useLinkedAccounts', () => {
|
||||
expect(onLinkStarted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useLinkedAccounts link dispatch', () => {
|
||||
// Steam is OpenID 2.0, not OAuth2 — the composable must call the client's
|
||||
// dedicated `linkSteam` (backed by `/link/steam`) instead of `linkSocial`
|
||||
// (backed by `/link-social`, which only resolves OAuth2 providers).
|
||||
it('routes Steam links through linkSteam and other providers through linkSocial', async () => {
|
||||
const linkSocial = vi.fn(async () => ({
|
||||
data: { status: true, redirect: false },
|
||||
error: null,
|
||||
}))
|
||||
const linkSteam = vi.fn(async () => ({
|
||||
data: { status: true, redirect: false },
|
||||
error: null,
|
||||
}))
|
||||
|
||||
// Separate composable instances: a successful link without a redirect
|
||||
// URL leaves `inFlight` set (the row refreshes in place), so a second
|
||||
// link call on the same instance would be a no-op.
|
||||
const steamHolder = await mountLinkedAccounts(fakeLinkedAccountsClient({ linkSteam }))
|
||||
await steamHolder.link('steam', 'Steam')
|
||||
expect(linkSteam).toHaveBeenCalledTimes(1)
|
||||
expect(linkSteam).toHaveBeenCalledWith({
|
||||
callbackURL: 'https://accounts.airi.build/ui/profile',
|
||||
errorCallbackURL: 'https://accounts.airi.build/ui/profile',
|
||||
})
|
||||
expect(linkSocial).not.toHaveBeenCalled()
|
||||
|
||||
const socialHolder = await mountLinkedAccounts(fakeLinkedAccountsClient({ linkSocial }))
|
||||
await socialHolder.link('google', 'Google')
|
||||
expect(linkSocial).toHaveBeenCalledTimes(1)
|
||||
expect(linkSocial).toHaveBeenCalledWith({
|
||||
provider: 'google',
|
||||
callbackURL: 'https://accounts.airi.build/ui/profile',
|
||||
errorCallbackURL: 'https://accounts.airi.build/ui/profile',
|
||||
})
|
||||
expect(linkSteam).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
async function mountLinkedAccounts(client: LinkedAccountsClient) {
|
||||
const holder: {
|
||||
linkedAccounts?: ReturnType<typeof useLinkedAccounts>
|
||||
} = {}
|
||||
const app = createSSRApp({
|
||||
setup() {
|
||||
holder.linkedAccounts = useLinkedAccounts({
|
||||
client,
|
||||
isAuthenticated: ref(false),
|
||||
describeError: () => '',
|
||||
buildCallbackURL: () => 'https://accounts.airi.build/ui/profile',
|
||||
messages: {
|
||||
listFailed: 'list failed',
|
||||
unlinkFailed: 'unlink failed',
|
||||
linkFailed: 'link failed',
|
||||
lastAccount: 'last account',
|
||||
unlinked: provider => `${provider} unlinked`,
|
||||
linkStarted: provider => `${provider} link started`,
|
||||
},
|
||||
})
|
||||
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
|
||||
await renderToString(app)
|
||||
if (!holder.linkedAccounts)
|
||||
throw new Error('Expected linked accounts composable to initialize')
|
||||
return holder.linkedAccounts
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { SteamOAuthStartArgs, SteamOAuthStartResult } from '../libs/steam-auth-client'
|
||||
|
||||
import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* Provider key for the social-link / unlink endpoints. Matches the values
|
||||
* better-auth recognises on `/api/auth/link-social` and `/api/auth/unlink-account`.
|
||||
* Provider key for the linked-account actions. OAuth2 providers go through
|
||||
* better-auth's `/link-social`; Steam is OpenID 2.0 and is routed to the
|
||||
* Steam client plugin's dedicated `linkSteam` method instead.
|
||||
*/
|
||||
export type LinkedProviderId = 'google' | 'github' | (string & {})
|
||||
|
||||
@@ -47,6 +50,19 @@ export interface LinkedAccountsClient {
|
||||
data: { url?: string, redirect?: boolean, status?: boolean } | null
|
||||
error: { message?: string, status?: number } | null
|
||||
}>
|
||||
/**
|
||||
* Starts linking the current user to a Steam account.
|
||||
*
|
||||
* Steam's web login is OpenID 2.0, not OAuth2, so better-auth's
|
||||
* `/link-social` can never resolve it as a `socialProviders` entry. The
|
||||
* server steam plugin exposes `/link/steam` instead, and `steamClient()`
|
||||
* surfaces that endpoint as this typed method — callers pass the raw
|
||||
* client rather than wrapping `linkSocial`.
|
||||
*/
|
||||
linkSteam: (args: SteamOAuthStartArgs) => Promise<{
|
||||
data: SteamOAuthStartResult | null
|
||||
error: { message?: string, status?: number } | null
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,13 +225,15 @@ export function useLinkedAccounts(args: UseLinkedAccountsArgs) {
|
||||
|
||||
try {
|
||||
const callbackURL = args.buildCallbackURL ? args.buildCallbackURL() : window.location.href
|
||||
const { data, error: apiError } = await args.client.linkSocial({
|
||||
provider: providerId,
|
||||
callbackURL,
|
||||
errorCallbackURL: callbackURL,
|
||||
})
|
||||
// Steam is OpenID 2.0, not OAuth2 — the server steam plugin exposes a
|
||||
// dedicated `/link/steam` endpoint, surfaced as `linkSteam` by the
|
||||
// client plugin. Every other provider uses `/link-social`.
|
||||
const result = providerId === 'steam'
|
||||
? await args.client.linkSteam({ callbackURL, errorCallbackURL: callbackURL })
|
||||
: await args.client.linkSocial({ provider: providerId, callbackURL, errorCallbackURL: callbackURL })
|
||||
const { data, error: apiError } = result
|
||||
if (apiError)
|
||||
throw new Error(apiError.message ?? 'linkSocial failed')
|
||||
throw new Error(apiError.message ?? 'link failed')
|
||||
if (data?.url) {
|
||||
args.onLinkStarted?.(providerId)
|
||||
window.location.assign(data.url)
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface OIDCFlowParams {
|
||||
*/
|
||||
clientSecret?: string
|
||||
/** Social provider hint — skips the server-side picker page. */
|
||||
provider?: 'google' | 'github'
|
||||
provider?: 'google' | 'github' | 'steam'
|
||||
}
|
||||
|
||||
export interface OIDCFlowState {
|
||||
|
||||
@@ -6,8 +6,9 @@ import { useAuthStore } from '../stores/auth'
|
||||
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from './auth-config'
|
||||
import { buildAuthorizationURL, persistFlowState } from './auth-oidc'
|
||||
import { SERVER_URL } from './server'
|
||||
import { steamClient } from './steam-auth-client'
|
||||
|
||||
export type OAuthProvider = 'google' | 'github'
|
||||
export type OAuthProvider = 'google' | 'github' | 'steam'
|
||||
|
||||
// NOTICE: reads the same localStorage key ('auth/v1/token') that useAuthStore's
|
||||
// `token` ref writes via useLocalStorage. We bypass the store here because
|
||||
@@ -20,6 +21,7 @@ export function getAuthToken(): string | null {
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: SERVER_URL,
|
||||
plugins: [steamClient()],
|
||||
fetchOptions: {
|
||||
// NOTICE: better-auth's client hardcodes `credentials: "include"` by default
|
||||
// (config.mjs L40), which causes cookies to be sent alongside the Authorization
|
||||
@@ -192,6 +194,12 @@ export async function signInOIDC(params: OIDCFlowParams) {
|
||||
return
|
||||
}
|
||||
|
||||
if (provider === 'steam') {
|
||||
// Steam is OpenID 2.0; only the Steam plugin endpoint can start it.
|
||||
await authClient.signIn.steam({ callbackURL: url.toString() })
|
||||
return
|
||||
}
|
||||
|
||||
await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: url.toString(),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { steamClient } from './steam-auth-client'
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('steamClient', () => {
|
||||
it('adds linkSteam, posting to /link/steam with the OAuth-style body', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({
|
||||
url: 'https://steamcommunity.com/openid/login?...',
|
||||
redirect: true,
|
||||
}))
|
||||
const client = createAuthClient({
|
||||
baseURL: 'https://api.airi.test',
|
||||
plugins: [steamClient()],
|
||||
fetchOptions: { customFetchImpl: fetchImpl },
|
||||
})
|
||||
|
||||
const result = await client.linkSteam({
|
||||
callbackURL: '/profile',
|
||||
errorCallbackURL: '/profile?error=steam',
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchImpl.mock.calls[0] ?? []
|
||||
expect(String(url)).toBe('https://api.airi.test/api/auth/link/steam')
|
||||
expect((init as RequestInit).method).toBe('POST')
|
||||
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
|
||||
callbackURL: '/profile',
|
||||
errorCallbackURL: '/profile?error=steam',
|
||||
})
|
||||
expect(result.data?.url).toBe('https://steamcommunity.com/openid/login?...')
|
||||
})
|
||||
|
||||
it('adds signIn.steam, posting to /sign-in/steam without a provider field', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => jsonResponse({
|
||||
url: 'https://steamcommunity.com/openid/login?...',
|
||||
redirect: true,
|
||||
}))
|
||||
const client = createAuthClient({
|
||||
baseURL: 'https://api.airi.test',
|
||||
plugins: [steamClient()],
|
||||
fetchOptions: { customFetchImpl: fetchImpl },
|
||||
})
|
||||
|
||||
const result = await client.signIn.steam({ callbackURL: '/profile' })
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchImpl.mock.calls[0] ?? []
|
||||
expect(String(url)).toBe('https://api.airi.test/api/auth/sign-in/steam')
|
||||
expect((init as RequestInit).method).toBe('POST')
|
||||
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ callbackURL: '/profile' })
|
||||
expect(result.data?.url).toBe('https://steamcommunity.com/openid/login?...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { BetterFetch } from '@better-fetch/fetch'
|
||||
|
||||
/**
|
||||
* Request body for starting a Steam OpenID sign-in or account link.
|
||||
*
|
||||
* Matches the server steam plugin's `SignInBodySchema` (`/sign-in/steam`
|
||||
* and `/link/steam`), which takes `callbackURL` without a `provider` field.
|
||||
*/
|
||||
export interface SteamOAuthStartArgs {
|
||||
callbackURL: string
|
||||
errorCallbackURL?: string
|
||||
disableRedirect?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect envelope both Steam endpoints return, mirroring better-auth's
|
||||
* `/sign-in/social` response shape (`{ url, redirect }`).
|
||||
*/
|
||||
export interface SteamOAuthStartResult {
|
||||
url?: string
|
||||
redirect?: boolean
|
||||
status?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side counterpart of the server `steam()` auth plugin.
|
||||
*
|
||||
* Adds typed `linkSteam` / `signIn.steam` actions backed by the plugin's
|
||||
* dedicated endpoints, so consumers don't hand-roll `/link/steam` /
|
||||
* `/sign-in/steam` requests. Steam's web login is OpenID 2.0, not OAuth2,
|
||||
* so better-auth's `/link-social` / `/sign-in/social` can never reach it —
|
||||
* `socialProviders` is a fixed OAuth2 list.
|
||||
*
|
||||
* Removal condition: better-auth natively supports OpenID 2.0 / Steam as a
|
||||
* `socialProviders` entry — then both this plugin and the server plugin's
|
||||
* custom endpoints collapse into standard provider configuration.
|
||||
*/
|
||||
export function steamClient() {
|
||||
return {
|
||||
id: 'steam-client',
|
||||
getActions: ($fetch: BetterFetch) => ({
|
||||
linkSteam: (args: SteamOAuthStartArgs) => $fetch<SteamOAuthStartResult>('/link/steam', {
|
||||
method: 'POST',
|
||||
body: args,
|
||||
}),
|
||||
signIn: {
|
||||
steam: (args: SteamOAuthStartArgs) => $fetch<SteamOAuthStartResult>('/sign-in/steam', {
|
||||
method: 'POST',
|
||||
body: args,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
Generated
+241
-1666
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ catalog:
|
||||
'@better-auth/cli': ^1.4.21
|
||||
'@better-auth/drizzle-adapter': ^1.6.5
|
||||
'@better-auth/oauth-provider': 1.5.6
|
||||
'@better-fetch/fetch': ^1.1.21
|
||||
'@capacitor/android': ^8.3.1
|
||||
'@capacitor/app': ^8.1.0
|
||||
'@capacitor/barcode-scanner': ^3.0.2
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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=...).
|
||||
|
||||
Reference in New Issue
Block a user