From 96b0cf811177dca83ea6d6e98088fa77e8676a11 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:02:16 +0300 Subject: [PATCH] feat(auth): sign in with an email address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the pin-code provider, which existed and was never reachable, and makes it the only branch that can create an account. Steam now resolves an existing connection instead of minting a user from a persona, and refuses when there is no account behind it — which is an answer the interface renders rather than an implicit signup. Delivery is a small provider-neutral POST rather than a vendor SDK: configure an endpoint, a key and a from address. With none of them set it logs the code outside production so a local sign-in works, and throws in production, because a screen that says "check your email" when nothing was sent leaves someone waiting instead of telling anybody. A person who has only ever signed in by email has no connected account, and the token says so with an empty value — the same one a server-to-server caller has always carried. --- apps/auth/src/email.ts | 61 +++++++++++++ apps/auth/src/index.ts | 171 +++++++++++++++++++++++------------ apps/auth/test/email.test.ts | 81 +++++++++++++++++ 3 files changed, 253 insertions(+), 60 deletions(-) create mode 100644 apps/auth/src/email.ts create mode 100644 apps/auth/test/email.test.ts diff --git a/apps/auth/src/email.ts b/apps/auth/src/email.ts new file mode 100644 index 00000000..e5cfa64b --- /dev/null +++ b/apps/auth/src/email.ts @@ -0,0 +1,61 @@ +/** + * Getting a pin code to a mailbox. + * + * Deliberately not tied to one mail vendor: it posts a small JSON body to + * whatever endpoint is configured, so swapping providers is configuration and + * not a code change. Three settings, all optional except in production — + * `EMAIL_SEND_URL`, `EMAIL_API_KEY`, `EMAIL_FROM`. + */ +export interface MailerConfig { + EMAIL_SEND_URL?: string; + EMAIL_API_KEY?: string; + EMAIL_FROM?: string; + NODE_ENV?: string; +} + +/** + * Send the code, or fail loudly. + * + * With no mailer configured this logs the code and carries on, which is what + * makes a local sign-in possible without a mail account. In production the + * same situation throws instead: a signup screen that says "check your email" + * when nothing was sent is worse than one that says it is broken, because the + * person waits instead of telling anybody. + */ +export async function sendVerificationCode( + config: MailerConfig, + email: string, + code: string +): Promise { + const configured = config.EMAIL_SEND_URL && config.EMAIL_API_KEY && config.EMAIL_FROM; + + if (!configured) { + if (config.NODE_ENV === 'production') { + throw new Error('Email delivery is not configured, so no sign-in code can be sent'); + } + console.log(`[auth] sign-in code for ${email}: ${code}`); + return; + } + + const response = await fetch(config.EMAIL_SEND_URL!, { + method: 'POST', + headers: { + authorization: `Bearer ${config.EMAIL_API_KEY}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + from: config.EMAIL_FROM, + to: [email], + subject: `${code} is your Nestri sign-in code`, + text: + `Your Nestri sign-in code is ${code}.\n\n` + + `It expires shortly. If you did not ask to sign in, you can ignore this.` + }) + }); + + if (!response.ok) { + // The body is included because the useful part of a delivery failure is + // always the provider's own message, and it is otherwise lost. + throw new Error(`Sending the sign-in code failed: ${response.status} ${await response.text()}`); + } +} diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 11336900..58831153 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -1,25 +1,55 @@ 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 { subjects } from '@nestri/core/auth/subjects'; -import { Database } from '@nestri/core/db/index'; -import { Env } from '@nestri/core/env'; +import { CodeUI } from '@nestri/auth/ui/code'; import { Actor } from '@nestri/core/actor'; -import { Identifier } from '@nestri/core/id'; +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'; + 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; }; +/** + * Enough of an address to be worth trying to deliver to. + * + * Deliberately loose: the only test that settles whether an address is real is + * whether the code arrives, and this flow already runs that test. What this + * catches is the empty box and the missing `@` — the cases where nothing could + * possibly be sent — so the screen can say so instead of pretending. + */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Which linked account a token names, for a person who may have none. + * + * An account rooted in an email address starts with nothing attached, so there + * is genuinely no linked account to name and the empty string says so. The + * middleware that reads this already treats an empty value as "no linked + * account", because a server-to-server caller has never had one either. + */ +async function firstSteamLink(userID: string): Promise { + const link = await LinkedAccount.findSteamByUser(userID); + return link?.id ?? ''; +} + export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { Env.init(env as unknown as Record); @@ -29,71 +59,71 @@ export default { namespace: env.AuthStorage }), 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 + // nothing — and a mistyped address that silently succeeds + // leaves someone waiting for mail that went nowhere. + ...CodeUI({ + copy: { code_info: "We'll email you a code to sign in." }, + sendCode: async () => {} + }), + sendCode: async (claims, code) => { + const email = claims.email?.trim().toLowerCase(); + if (!email || !EMAIL_RE.test(email)) { + return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' }; + } + await sendVerificationCode(env, email, code); + } + }), steam: SteamProvider(), ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY }) }, async success(context, response) { + if (response.provider === 'code') { + const email = (response.claims as Record).email!.trim().toLowerCase(); + const { userID } = await Identity.fromVerifiedEmail({ email }); + + // Every user needs a personal team, because `machine.teamId` + // is notNull and registering a host has nowhere to put it + // otherwise. Idempotent, so running it on every sign-in is + // also what backfills the accounts made before it existed. + const linkedAccountID = await firstSteamLink(userID); + await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, () => + Team.ensurePersonal({ displayName: email.split('@')[0]! }) + ); + + return context.subject('user', { userID, linkedAccountID }); + } + if (response.provider === 'steam') { const { steamid } = response; - const profileUrl = new URL( - 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/' - ); - profileUrl.searchParams.set('key', env.STEAM_API_KEY); - profileUrl.searchParams.set('steamids', steamid); - const profileRes = await fetch(profileUrl.toString()); - const profileData = (await profileRes.json()) as { - response?: { players?: Array> }; - }; - - const player = profileData?.response?.players?.[0] as any; - const personaname: string = player?.personaname ?? 'Player'; - const avatarfull: string = player?.avatarfull; - - const { userID, linkedAccountID } = await Database.transaction(async () => { - const existing = await LinkedAccount.findByProvider({ - provider: 'steam', - providerAccountId: steamid - }); - - if (existing) { - const user = await User.fromID(existing.userId); - if (!user) throw new Error('User not found for linked account'); - return { userID: user.id, linkedAccountID: existing.id }; - } - - const newUserID = Identifier.ascending('user'); - await User.create({ - id: newUserID, - name: personaname, - email: undefined, - emailVerified: false, - image: avatarfull ?? null - }); - - const newLinkedAccountID = Identifier.ascending('linkedAccount'); - await LinkedAccount.create({ - id: newLinkedAccountID, - userId: newUserID, - provider: 'steam', - providerAccountId: steamid, - profile: player ?? {} - }); - - return { userID: newUserID, linkedAccountID: newLinkedAccountID }; + // 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 }); - // Every user needs a personal team, because `machine.teamId` is - // notNull and registering a host has nowhere to put it - // otherwise. `packages/core/CLAUDE.md` documented this call as - // part of the login flow and it was never actually made, so no - // user in the database has one. ref(d-0048) - // - // Run on every login rather than only on creation: that is what - // backfills the accounts made before this existed, and - // `ensurePersonal` is idempotent precisely so it can be. + // 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: personaname }) + Team.ensurePersonal({ + displayName: user?.name || (player?.personaname as string) || 'Player' + }) ); return context.subject('user', { @@ -111,7 +141,7 @@ export default { profile }); - // Same reason as the Steam branch above. The SSH path creates + // 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 } }, () => @@ -135,3 +165,24 @@ 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/email.test.ts b/apps/auth/test/email.test.ts new file mode 100644 index 00000000..1961caed --- /dev/null +++ b/apps/auth/test/email.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; + +import { sendVerificationCode } from '../src/email.js'; + +describe('sending a sign-in code', () => { + test('with nothing configured outside production, it does not block a sign-in', async () => { + await sendVerificationCode({ NODE_ENV: 'development' }, 'ada@example.com', '123456'); + }); + + test('with nothing configured in production, it says so instead of pretending', async () => { + await expect( + sendVerificationCode({ NODE_ENV: 'production' }, 'ada@example.com', '123456') + ).rejects.toThrow(/not configured/); + }); + + test('a configured mailer is called with the address and the code', async () => { + let seen: { url: string; body: any; auth: string | null } | null = null; + const original = globalThis.fetch; + globalThis.fetch = (async (url: any, init: any) => { + seen = { + url: String(url), + body: JSON.parse(init.body), + auth: new Headers(init.headers).get('authorization') + }; + return new Response('{}', { status: 200 }); + }) as unknown as typeof fetch; + + try { + await sendVerificationCode( + { + NODE_ENV: 'production', + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io' + }, + 'ada@example.com', + '123456' + ); + } finally { + globalThis.fetch = original; + } + + expect(seen!.url).toBe('https://mail.example.com/send'); + expect(seen!.auth).toBe('Bearer key'); + expect(seen!.body.to).toEqual(['ada@example.com']); + expect(seen!.body.from).toBe('hello@nestri.io'); + expect(seen!.body.text).toContain('123456'); + }); + + test('a refusal from the mailer is not swallowed', async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response('over quota', { status: 429 })) as unknown as typeof fetch; + try { + await expect( + sendVerificationCode( + { + EMAIL_SEND_URL: 'https://mail.example.com/send', + EMAIL_API_KEY: 'key', + EMAIL_FROM: 'hello@nestri.io' + }, + 'ada@example.com', + '123456' + ) + ).rejects.toThrow(/over quota/); + } finally { + globalThis.fetch = original; + } + }); +}); + +describe('the worker itself', () => { + // Cheap, and it catches the thing a type check cannot: the sign-in screen + // lives in a `.tsx` file, and whether that file can be imported across a + // package boundary at run time is decided by the package's export map + // rather than by the compiler. + test('loads, with every provider it wires resolvable', async () => { + const worker = await import('../src/index.js'); + expect(typeof worker.default.fetch).toBe('function'); + }); +});