mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
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:
61
apps/auth/src/email.ts
Normal file
61
apps/auth/src/email.ts
Normal 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()}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
81
apps/auth/test/email.test.ts
Normal file
81
apps/auth/test/email.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user