feat(auth): sign in with an email address

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.
This commit is contained in:
Wanjohi
2026-09-05 00:02:16 +03:00
parent 2a1b7abe9a
commit 96b0cf8111
3 changed files with 253 additions and 60 deletions

61
apps/auth/src/email.ts Normal file
View File

@@ -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<void> {
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()}`);
}
}

View File

@@ -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<string> {
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<string, unknown>);
@@ -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<string, string>).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<Record<string, unknown>> };
};
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<Record<string, unknown> | 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<Record<string, unknown>> };
};
return data?.response?.players?.[0] ?? null;
} catch {
// A stale display name is not a reason to refuse a sign-in.
return null;
}
}