diff --git a/alchemy.run.ts b/alchemy.run.ts index c58c7170..c943941e 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -5,7 +5,6 @@ import { Redacted } from 'effect'; import * as Effect from 'effect/Effect'; const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!); -const sshAuthKey = process.env.SSH_AUTH_KEY || 'dev-ssh-auth-key-change-in-prod'; const adminSharedSecret = process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod'; @@ -41,11 +40,12 @@ export const Auth = Effect.gen(function* () { return yield* Cloudflare.Worker('auth', { main: 'apps/auth/src/index.ts', compatibility: { flags: ['nodejs_compat'] }, + // No Steam or SSH settings: the issuer serves one provider, and it is + // the email one. Linking a Steam account is `apps/api`'s job and its + // key is bound there. env: { AuthStorage, - HYPERDRIVE: Database, - STEAM_API_KEY: steamApiKey, - SSH_AUTH_KEY: sshAuthKey + HYPERDRIVE: Database }, ...(isPermanent ? { observability: { enabled: true } } : {}) }); diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 58831153..b53e382f 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -1,17 +1,13 @@ import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types'; import { issuer } from '@nestri/auth/index'; import { CodeProvider } from '@nestri/auth/provider/code'; -import { SshProvider } from '@nestri/auth/provider/ssh'; -import { SteamProvider } from '@nestri/auth/provider/steam'; import { CloudflareStorage } from '@nestri/auth/storage/cloudflare'; import { CodeUI } from '@nestri/auth/ui/code'; import { Actor } from '@nestri/core/actor'; import { subjects } from '@nestri/core/auth/subjects'; import { Env } from '@nestri/core/env'; -import { Steam } from '@nestri/core/steam/index'; import { Team } from '@nestri/core/team/index'; import { Identity } from '@nestri/core/user/identity'; -import { User } from '@nestri/core/user/index'; import { LinkedAccount } from '@nestri/core/user/linked-account'; import { sendVerificationCode } from './email.js'; @@ -19,12 +15,10 @@ import { sendVerificationCode } from './email.js'; type Env = { AuthStorage: KVNamespace; HYPERDRIVE: Hyperdrive; - STEAM_API_KEY: string; - SSH_AUTH_KEY: string; EMAIL_SEND_URL?: string; EMAIL_API_KEY?: string; EMAIL_FROM?: string; - NODE_ENV?: string; + EMAIL_DEV_LOG?: string; }; /** @@ -58,10 +52,21 @@ export default { storage: CloudflareStorage({ namespace: env.AuthStorage }), + // One provider, on purpose. + // + // Verifying an email address is the only thing that brings an + // account into existence. Steam and SSH were sign-ins here as well, + // and both could mint a user from a persona or a key — which makes + // the account only as recoverable as the thing that made it, and + // gives one person as many accounts as they have gaming logins. + // + // They are unwired rather than deleted: the providers still exist + // under `packages/auth/src/provider/`, because connecting a Steam + // account is something this product still does. It does it from + // `apps/api`'s `POST /steam/link`, against a user who already + // exists — which is a connection hanging off an identity, and not + // an identity of its own. ref(d-0048) providers: { - // Verifying an email address is what creates an account. It is - // listed first because it is the only branch below that is - // allowed to bring a person into existence. ref(d-0048) code: CodeProvider({ // The UI, with delivery replaced. `CodeUI`'s own hook cannot // report a bad address back to the screen — it returns @@ -78,9 +83,7 @@ export default { } await sendVerificationCode(env, email, code); } - }), - steam: SteamProvider(), - ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY }) + }) }, async success(context, response) { if (response.provider === 'code') { @@ -99,65 +102,6 @@ export default { return context.subject('user', { userID, linkedAccountID }); } - if (response.provider === 'steam') { - const { steamid } = response; - - // Signing in with Steam resolves an account; it never - // creates one. A Steam account is something a person - // attaches to an account they already have, so losing it - // costs them a link and not everything they own. Accounts - // that predate the rule already have the link this finds, - // so they keep working unchanged. ref(d-0048) - const { userID, linkedAccountID } = await Identity.resolveSteamLogin({ - steamId: steamid - }); - - // The persona is refreshed on the way through, because this - // is the only moment the current one is in hand. - const player = await steamProfile(env.STEAM_API_KEY, steamid); - if (player) { - await LinkedAccount.updateProfile({ id: linkedAccountID, profile: player }); - } - - const user = await User.fromID(userID); - await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => - Team.ensurePersonal({ - displayName: user?.name || (player?.personaname as string) || 'Player' - }) - ); - - return context.subject('user', { - userID, - linkedAccountID - }); - } - - if (response.provider === 'ssh') { - const { fingerprint, steamId, username, profile } = response; - const { userID, linkedAccountID } = await Steam.resolveSshIdentity({ - fingerprint, - steamId, - username, - profile - }); - - // Same reason as the branch above. The SSH path creates - // users too, so leaving it out would give a host registered - // from `nessh` nowhere to live. - await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => - // `username` is optional on the SSH path — a key can arrive - // before a persona does. The slug only has to be derivable, - // not pretty, and a rename is a later problem. - Team.ensurePersonal({ displayName: username ?? 'Player' }) - ); - - return context.subject('user', { - userID, - linkedAccountID, - fingerprint - }); - } - throw new Error('Unknown provider'); } }); @@ -165,24 +109,3 @@ export default { return inner.fetch(request, env, ctx); } }; - -/** The current persona for a Steam account, or null if Steam did not answer. */ -async function steamProfile( - apiKey: string, - steamid: string -): Promise | null> { - try { - const profileUrl = new URL('https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/'); - profileUrl.searchParams.set('key', apiKey); - profileUrl.searchParams.set('steamids', steamid); - - const res = await fetch(profileUrl.toString()); - const data = (await res.json()) as { - response?: { players?: Array> }; - }; - return data?.response?.players?.[0] ?? null; - } catch { - // A stale display name is not a reason to refuse a sign-in. - return null; - } -} diff --git a/apps/auth/test/worker.test.ts b/apps/auth/test/worker.test.ts index 9e0de1e1..bcc47647 100644 --- a/apps/auth/test/worker.test.ts +++ b/apps/auth/test/worker.test.ts @@ -1,125 +1,148 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { createClient } from '@nestri/auth/client'; import { issuer } from '@nestri/auth/index'; -import { SshProvider } from '@nestri/auth/provider/ssh'; -import { SteamProvider } from '@nestri/auth/provider/steam'; +import { CodeProvider } from '@nestri/auth/provider/code'; import { MemoryStorage } from '@nestri/auth/storage/memory'; +import { CodeUI } from '@nestri/auth/ui/code'; import { subjects } from '@nestri/core/auth/subjects'; +/** + * The issuer the worker builds, with the database taken out. + * + * The provider list is the load-bearing part and is the same one + * `apps/auth/src/index.ts` passes: one entry, `code`. `success` is a stub + * because what the real one does — resolve an address to a user and give it a + * team — is core's behaviour and is held by core's own tests. What this file + * holds is the shape of the issuer around it. + */ +let lastCode = ''; const storage = MemoryStorage(); - const auth = issuer({ subjects, storage, allow: async () => true, providers: { - steam: SteamProvider(), - ssh: SshProvider({ sshAuthKey: 'test-ssh-key' }) + code: CodeProvider({ + ...CodeUI({ copy: { code_info: 'test' }, sendCode: async () => {} }), + sendCode: async (_claims, code) => { + lastCode = code; + } + }) }, async success(context, response) { - if (response.provider === 'steam') { + if (response.provider === 'code') { return context.subject('user', { userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' }); } - if (response.provider === 'ssh') { - return context.subject('user', { - userID: 'usr_test123', - linkedAccountID: 'lac_test456', - fingerprint: response.fingerprint - }); - } - throw new Error('unknown provider'); + throw new Error('Unknown provider'); } }); -beforeEach(() => { - globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - - if (url.includes('steamcommunity.com/openid/login')) { - return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 }); - } - - if (url.includes('api.steampowered.com')) { - return new Response( - JSON.stringify({ - response: { - players: [ - { - personaname: 'TestPlayer', - avatarfull: - 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg', - steamid: '76561197960287956' - } - ] - } - }), - { status: 200 } - ); - } - - return new Response('not found', { status: 404 }); - }) as unknown as typeof fetch; -}); - -afterEach(() => { - globalThis.fetch = fetch; -}); - -describe('Steam auth flow', () => { - test('authorize redirects to Steam OpenID', async () => { +/** + * Signing in with a gaming account or a key is gone, and this is the assertion + * that keeps it gone. + * + * Both used to be providers here and both could bring a user into existence + * from something that is not an address, which is the shape the account model + * no longer has. The provider implementations still exist and can be wired + * back; what must not happen quietly is them becoming reachable again. + */ +describe('what the issuer serves', () => { + test('there is no sign-in with a gaming account', async () => { const response = await auth.request('https://auth.internal/steam/authorize'); - expect(response.status).toBe(302); - expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/); + expect(response.status).toBe(404); }); - test('full code flow and token verification', async () => { - const client = createClient({ - issuer: 'https://auth.internal', - clientID: 'api', - fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + test('there is no sign-in with a key', async () => { + const response = await auth.request('https://auth.internal/ssh/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fingerprint: 'SHA256:abc123', steamId: '76561198012345678' }) }); + expect(response.status).toBe(404); + }); - const { challenge, url } = await client.authorize( - 'https://client.example.com/callback', - 'code', - { pkce: true, provider: 'steam' } - ); + test('asking for a code is where a sign-in starts', async () => { + const response = await auth.request('https://auth.internal/code/authorize'); + expect(response.status).toBe(200); + }); +}); - // Step 1: hit the authorize URL → redirects to Steam OpenID - const authResponse = await auth.request(url); - expect(authResponse.status).toBe(302); - const cookie = authResponse.headers.get('set-cookie')!; - expect(cookie).toBeDefined(); +/** + * A cookie jar, because this flow needs two cookies at once. + * + * `/authorize` sets the one holding the authorization, the code provider sets + * the one holding its own state, and both have to be presented at the verify + * step. `Headers.get('set-cookie')` returns only the first of several, which + * silently drops one of them. + */ +function jar() { + const cookies = new Map(); + return { + absorb(response: Response) { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const index = pair!.indexOf('='); + cookies.set(pair!.slice(0, index), pair!.slice(index + 1)); + } + }, + header() { + return [...cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } + }; +} - // Step 2: simulate Steam redirecting back to our callback with valid OpenID params - const callbackUrl = - 'https://auth.internal/steam/callback?' + - 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + - 'openid.mode=id_res&' + - 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + - 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + - 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; +/** Ask for a code, redeem it, and come back holding tokens. */ +async function signIn() { + const client = createClient({ + issuer: 'https://auth.internal', + clientID: 'api', + fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + }); - const callbackResponse = await auth.request(callbackUrl, { - headers: { cookie } - }); - expect(callbackResponse.status).toBe(302); + const { challenge, url } = await client.authorize('https://client.example.com/callback', 'code', { + pkce: true, + provider: 'code' + }); - const location = new URL(callbackResponse.headers.get('location')!); - const code = location.searchParams.get('code'); - expect(code).not.toBeNull(); + const cookies = jar(); + cookies.absorb(await auth.request(url)); + expect(cookies.header()).not.toBe(''); - const exchanged = await client.exchange( - code!, - 'https://client.example.com/callback', - challenge.verifier - ); - if (exchanged.err) throw exchanged.err; - const tokens = exchanged.tokens!; + const requested = await auth.request('https://auth.internal/code/authorize', { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'request', email: 'ada@example.com' }) + }); + cookies.absorb(requested); + expect(lastCode).not.toBe(''); + + const verified = await auth.request('https://auth.internal/code/authorize', { + method: 'POST', + headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ action: 'verify', code: lastCode }) + }); + expect(verified.status).toBe(302); + + const location = new URL(verified.headers.get('location')!); + const code = location.searchParams.get('code'); + expect(code).not.toBeNull(); + + const exchanged = await client.exchange( + code!, + 'https://client.example.com/callback', + challenge.verifier + ); + if (exchanged.err) throw exchanged.err; + return { client, tokens: exchanged.tokens! }; +} + +describe('signing in with an email address', () => { + test('a redeemed code becomes tokens that verify', async () => { + const { client, tokens } = await signIn(); expect(tokens.access).toBeString(); expect(tokens.refresh).toBeString(); @@ -130,88 +153,15 @@ describe('Steam auth flow', () => { type: 'user', properties: { userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' } }); }); }); -describe('SSH login', () => { - test('valid login returns tokens', async () => { - const loginResponse = await auth.request('https://auth.internal/ssh/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer test-ssh-key' - }, - body: JSON.stringify({ - fingerprint: 'SHA256:abc123', - steamId: '76561198012345678' - }) - }); - - expect(loginResponse.status).toBe(200); - const body: any = await loginResponse.json(); - expect(body.accessToken).toBeString(); - expect(body.refreshToken).toBeString(); - }); - - test('invalid auth key returns 401', async () => { - const response = await auth.request('https://auth.internal/ssh/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer wrong-key' - }, - body: JSON.stringify({ - fingerprint: 'SHA256:abc123', - steamId: '76561198012345678' - }) - }); - - expect(response.status).toBe(401); - }); -}); - describe('User info', () => { - async function getTokens() { - const client = createClient({ - issuer: 'https://auth.internal', - clientID: 'api', - fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) - }); - - const { challenge, url } = await client.authorize( - 'https://client.example.com/callback', - 'code', - { pkce: true, provider: 'steam' } - ); - - const authResponse = await auth.request(url); - const cookie = authResponse.headers.get('set-cookie')!; - - const callbackUrl = - 'https://auth.internal/steam/callback?' + - 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + - 'openid.mode=id_res&' + - 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + - 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + - 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; - - const callbackResponse = await auth.request(callbackUrl, { headers: { cookie } }); - const location = new URL(callbackResponse.headers.get('location')!); - const code = location.searchParams.get('code'); - const exchanged = await client.exchange( - code!, - 'https://client.example.com/callback', - challenge.verifier - ); - if (exchanged.err) throw exchanged.err; - return { client, tokens: exchanged.tokens! }; - } - test('returns subject properties for valid access token', async () => { - const { tokens } = await getTokens(); + const { tokens } = await signIn(); const infoRes = await auth.request('https://auth.internal/userinfo', { headers: { Authorization: `Bearer ${tokens.access}` } @@ -221,7 +171,7 @@ describe('User info', () => { const userinfo = await infoRes.json(); expect(userinfo).toMatchObject({ userID: 'usr_test123', - linkedAccountID: 'lac_test456' + linkedAccountID: '' }); }); });