Files
netris-nestri/packages/auth/src/provider/password.ts
Wanjohi eedb143b46 refactor(auth): describe sign-in screens as data, draw them in one place
Providers no longer return a `Response`. Each one says what it needs from
the person — an address, a pin, a yes-or-no — as a `Screen`, and a single
`Renderer` decides how that is drawn.

The old arrangement made every provider a small web framework. It had to
know about markup, about the stylesheet's attribute names, about how a page
is assembled, so each grew its own callback signature and its own copy of
`new Response(jsx.toString())`. Three consequences, all of them visible in
the tree before this change:

- The device flow never got a design at all. Its two pages were built by
  concatenating HTML strings, with an inline `style` on the user code, and
  six of its replies were `text/plain` — unstyled black-on-white in the
  middle of signing in, which is also what a person got when their sign-in
  cookie expired.
- The password screens were drifting. They were written against attribute
  names the stylesheet no longer had, and nobody noticed because password
  sign-in is not switched on. They are deleted here rather than repaired;
  the flow is now six screen descriptions and no markup.
- A provider could not be named or marked without editing the library. The
  brand marks and display names were two hardcoded records inside the code
  that drew the chooser, so anything missing from them rendered as its own
  lowercase identifier with no icon. Providers now declare `display`
  themselves, and the chooser is built from what they say.

Also removes the theme global. It was `globalThis`, with a comment
conceding as much, which made every component depend on something invisible
at the call site — untestable in isolation, and shared mutable state on a
runtime that keeps one module instance across requests. The theme is now a
closure argument, and the same change shrinks `Theme` to the handful of
values a deployment sets that the stylesheet cannot.

Adding a screen now touches no CSS, and swapping the presentation layer
means implementing one method. The code flow's tests demonstrate the
second: they render screens as JSON.

Behaviour is unchanged. Status codes, cookies and the confirmation step are
the same, which the device tests cover unmodified.
2026-09-18 00:16:33 +03:00

594 lines
16 KiB
TypeScript

import { v1 } from '@standard-schema/spec';
/**
* Configures a provider that supports username and password authentication. This is usually
* paired with the `PasswordUI`.
*
* ```ts
* import { PasswordUI } from "@nestri/auth/ui/password"
* import { PasswordProvider } from "@nestri/auth/provider/password"
*
* export default issuer({
* providers: {
* password: PasswordProvider(
* PasswordUI({
* copy: {
* error_email_taken: "This email is already taken."
* },
* sendCode: (email, code) => console.log(email, code)
* })
* )
* },
* // ...
* })
* ```
*
* Behind the scenes, the `PasswordProvider` asks its config what to put on
* each screen. Each callback returns a `Screen` — a description of what is
* being asked for — and the issuer's renderer decides how it is drawn.
*
* ```ts
* PasswordProvider({
* // ...
* login: (req, form, error) => Promise<Screen>
* register: (req, state, form, error) => Promise<Screen>
* change: (req, state, form, error) => Promise<Screen>
* })
* ```
*
* @packageDocumentation
*/
import { UnknownStateError } from '../error.js';
import { generateUnbiasedDigits, timingSafeCompare } from '../random.js';
import { Storage } from '../storage/storage.js';
import { MARK_PASSWORD } from '../ui/mark.js';
import type { Screen } from '../ui/screen.js';
import { Provider } from './provider.js';
/**
* @internal
*/
export interface PasswordHasher<T> {
hash(password: string): Promise<T>;
verify(password: string, compare: T): Promise<boolean>;
}
export interface PasswordConfig {
/**
* @internal
*/
length?: number;
/**
* @internal
*/
hasher?: PasswordHasher<any>;
/**
* The request handler to generate the UI for the login screen.
*
* In case of an error, this is called again with the `error`.
*
* Returns a `Screen` describing what to ask for, not a rendered page.
*/
login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise<Screen>;
/**
* The request handler to generate the UI for the register screen.
*
* Also passes in the current `state` of the flow and any `error` that occurred.
*
* Returns a `Screen` describing what to ask for, not a rendered page.
*/
register: (
req: Request,
state: PasswordRegisterState,
form?: FormData,
error?: PasswordRegisterError
) => Promise<Screen>;
/**
* The request handler to generate the UI for the change password screen.
*
* Also passes in the current `state` of the flow and any `error` that occurred.
*
* Returns a `Screen` describing what to ask for, not a rendered page.
*/
change: (
req: Request,
state: PasswordChangeState,
form?: FormData,
error?: PasswordChangeError
) => Promise<Screen>;
/**
* Callback to send the confirmation pin code to the user.
*
* @example
* ```ts
* {
* sendCode: async (email, code) => {
* // Send an email with the code
* }
* }
* ```
*/
sendCode: (email: string, code: string) => Promise<void>;
/**
* Callback to validate the password on sign up and password reset.
*
* @example
* ```ts
* {
* validatePassword: (password) => {
* return password.length < 8 ? "Password must be at least 8 characters" : undefined
* }
* }
* ```
*/
validatePassword?:
| v1.StandardSchema
| ((password: string) => Promise<string | undefined> | string | undefined);
}
/**
* The states that can happen on the register screen.
*
* | State | Description |
* | ----- | ----------- |
* | `start` | The user is asked to enter their email address and password to start the flow. |
* | `code` | The user needs to enter the pin code to verify their email. |
*/
export type PasswordRegisterState =
| {
type: 'start';
}
| {
type: 'code';
code: string;
email: string;
password: string;
};
/**
* The errors that can happen on the register screen.
*
* | Error | Description |
* | ----- | ----------- |
* | `email_taken` | The email is already taken. |
* | `invalid_email` | The email is invalid. |
* | `invalid_code` | The code is invalid. |
* | `invalid_password` | The password is invalid. |
* | `password_mismatch` | The passwords do not match. |
*/
export type PasswordRegisterError =
| {
type: 'invalid_code';
}
| {
type: 'email_taken';
}
| {
type: 'invalid_email';
}
| {
type: 'invalid_password';
}
| {
type: 'password_mismatch';
}
| {
type: 'validation_error';
message?: string;
};
/**
* The state of the password change flow.
*
* | State | Description |
* | ----- | ----------- |
* | `start` | The user is asked to enter their email address to start the flow. |
* | `code` | The user needs to enter the pin code to verify their email. |
* | `update` | The user is asked to enter their new password and confirm it. |
*/
export type PasswordChangeState =
| {
type: 'start';
redirect: string;
}
| {
type: 'code';
code: string;
email: string;
redirect: string;
}
| {
type: 'update';
redirect: string;
email: string;
};
/**
* The errors that can happen on the change password screen.
*
* | Error | Description |
* | ----- | ----------- |
* | `invalid_email` | The email is invalid. |
* | `invalid_code` | The code is invalid. |
* | `invalid_password` | The password is invalid. |
* | `password_mismatch` | The passwords do not match. |
*/
export type PasswordChangeError =
| {
type: 'invalid_email';
}
| {
type: 'invalid_code';
}
| {
type: 'invalid_password';
}
| {
type: 'password_mismatch';
}
| {
type: 'validation_error';
message: string;
};
/**
* The errors that can happen on the login screen.
*
* | Error | Description |
* | ----- | ----------- |
* | `invalid_email` | The email is invalid. |
* | `invalid_password` | The password is invalid. |
*/
export type PasswordLoginError =
| {
type: 'invalid_password';
}
| {
type: 'invalid_email';
};
export function PasswordProvider(config: PasswordConfig): Provider<{ email: string }> {
const hasher = config.hasher ?? ScryptHasher();
function generate() {
return generateUnbiasedDigits(6);
}
return {
type: 'password',
display: { name: 'Password', icon: MARK_PASSWORD },
init(routes, ctx) {
routes.get('/authorize', async (c) => ctx.screen(c, await config.login(c.req.raw)));
routes.post('/authorize', async (c) => {
const fd = await c.req.formData();
async function error(err: PasswordLoginError) {
return ctx.screen(c, await config.login(c.req.raw, fd, err));
}
const email = fd.get('email')?.toString()?.toLowerCase();
if (!email) return error({ type: 'invalid_email' });
const hash = await Storage.get<HashedPassword>(ctx.storage, ['email', email, 'password']);
const password = fd.get('password')?.toString();
if (!password || !hash || !(await hasher.verify(password, hash)))
return error({ type: 'invalid_password' });
return ctx.success(
c,
{
email: email
},
{
invalidate: async (subject) => {
await Storage.set(ctx.storage, ['email', email, 'subject'], subject);
}
}
);
});
routes.get('/register', async (c) => {
const state: PasswordRegisterState = {
type: 'start'
};
await ctx.set(c, 'provider', 60 * 60 * 24, state);
return ctx.screen(c, await config.register(c.req.raw, state));
});
routes.post('/register', async (c) => {
const fd = await c.req.formData();
const email = fd.get('email')?.toString()?.toLowerCase();
const action = fd.get('action')?.toString();
const provider = await ctx.get<PasswordRegisterState>(c, 'provider');
async function transition(next: PasswordRegisterState, err?: PasswordRegisterError) {
await ctx.set<PasswordRegisterState>(c, 'provider', 60 * 60 * 24, next);
return ctx.screen(c, await config.register(c.req.raw, next, fd, err));
}
if (action === 'register' && provider.type === 'start') {
const password = fd.get('password')?.toString();
const repeat = fd.get('repeat')?.toString();
if (!email) return transition(provider, { type: 'invalid_email' });
if (!password) return transition(provider, { type: 'invalid_password' });
if (password !== repeat) return transition(provider, { type: 'password_mismatch' });
if (config.validatePassword) {
let validationError: string | undefined;
try {
if (typeof config.validatePassword === 'function') {
validationError = await config.validatePassword(password);
} else {
const res = await config.validatePassword['~standard'].validate(password);
if (res.issues?.length) {
throw new Error(res.issues.map((issue) => issue.message).join(', '));
}
}
} catch (error) {
validationError = error instanceof Error ? error.message : undefined;
}
if (validationError)
return transition(provider, {
type: 'validation_error',
message: validationError
});
}
const existing = await Storage.get(ctx.storage, ['email', email, 'password']);
if (existing) return transition(provider, { type: 'email_taken' });
const code = generate();
await config.sendCode(email, code);
return transition({
type: 'code',
code,
password: await hasher.hash(password),
email
});
}
if (action === 'register' && provider.type === 'code') {
const code = generate();
await config.sendCode(provider.email, code);
return transition({
type: 'code',
code,
password: provider.password,
email: provider.email
});
}
if (action === 'verify' && provider.type === 'code') {
const code = fd.get('code')?.toString();
if (!code || !timingSafeCompare(code, provider.code))
return transition(provider, { type: 'invalid_code' });
const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']);
if (existing) return transition({ type: 'start' }, { type: 'email_taken' });
await Storage.set(ctx.storage, ['email', provider.email, 'password'], provider.password);
return ctx.success(c, {
email: provider.email
});
}
return transition({ type: 'start' });
});
routes.get('/change', async (c) => {
let redirect = c.req.query('redirect_uri') || getRelativeUrl(c, './authorize');
const state: PasswordChangeState = {
type: 'start',
redirect
};
await ctx.set(c, 'provider', 60 * 60 * 24, state);
return ctx.screen(c, await config.change(c.req.raw, state));
});
routes.post('/change', async (c) => {
const fd = await c.req.formData();
const action = fd.get('action')?.toString();
const provider = await ctx.get<PasswordChangeState>(c, 'provider');
if (!provider) throw new UnknownStateError();
async function transition(next: PasswordChangeState, err?: PasswordChangeError) {
await ctx.set<PasswordChangeState>(c, 'provider', 60 * 60 * 24, next);
return ctx.screen(c, await config.change(c.req.raw, next, fd, err));
}
if (action === 'code') {
const email = fd.get('email')?.toString()?.toLowerCase();
if (!email)
return transition(
{ type: 'start', redirect: provider.redirect },
{ type: 'invalid_email' }
);
const code = generate();
await config.sendCode(email, code);
return transition({
type: 'code',
code,
email,
redirect: provider.redirect
});
}
if (action === 'verify' && provider.type === 'code') {
const code = fd.get('code')?.toString();
if (!code || !timingSafeCompare(code, provider.code))
return transition(provider, { type: 'invalid_code' });
return transition({
type: 'update',
email: provider.email,
redirect: provider.redirect
});
}
if (action === 'update' && provider.type === 'update') {
const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']);
if (!existing) return c.redirect(provider.redirect, 302);
const password = fd.get('password')?.toString();
const repeat = fd.get('repeat')?.toString();
if (!password) return transition(provider, { type: 'invalid_password' });
if (password !== repeat) return transition(provider, { type: 'password_mismatch' });
if (config.validatePassword) {
let validationError: string | undefined;
try {
if (typeof config.validatePassword === 'function') {
validationError = await config.validatePassword(password);
} else {
const res = await config.validatePassword['~standard'].validate(password);
if (res.issues?.length) {
throw new Error(res.issues.map((issue) => issue.message).join(', '));
}
}
} catch (error) {
validationError = error instanceof Error ? error.message : undefined;
}
if (validationError)
return transition(provider, {
type: 'validation_error',
message: validationError
});
}
await Storage.set(
ctx.storage,
['email', provider.email, 'password'],
await hasher.hash(password)
);
const subject = await Storage.get<string>(ctx.storage, [
'email',
provider.email,
'subject'
]);
if (subject) await ctx.invalidate(subject);
return c.redirect(provider.redirect, 302);
}
return transition({ type: 'start', redirect: provider.redirect });
});
}
};
}
import { TextEncoder } from 'node:util';
import * as jose from 'jose';
interface HashedPassword {}
/**
* @internal
*/
export function PBKDF2Hasher(opts?: { iterations?: number }): PasswordHasher<{
hash: string;
salt: string;
iterations: number;
}> {
const iterations = opts?.iterations ?? 600000;
return {
async hash(password) {
const encoder = new TextEncoder();
const bytes = encoder.encode(password);
const salt = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, [
'deriveBits'
]);
const hash = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
hash: 'SHA-256',
salt: salt,
iterations
},
keyMaterial,
256
);
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
const saltBase64 = jose.base64url.encode(salt);
return {
hash: hashBase64,
salt: saltBase64,
iterations
};
},
async verify(password, compare) {
const encoder = new TextEncoder();
const passwordBytes = encoder.encode(password);
const salt = jose.base64url.decode(compare.salt);
const params = {
name: 'PBKDF2',
hash: 'SHA-256',
salt,
iterations: compare.iterations
};
const keyMaterial = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, [
'deriveBits'
]);
const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256);
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
return hashBase64 === compare.hash;
}
};
}
import { timingSafeEqual, randomBytes, scrypt } from 'node:crypto';
import { getRelativeUrl } from '../util.js';
/**
* @internal
*/
export function ScryptHasher(opts?: { N?: number; r?: number; p?: number }): PasswordHasher<{
hash: string;
salt: string;
N: number;
r: number;
p: number;
}> {
const N = opts?.N ?? 16384;
const r = opts?.r ?? 8;
const p = opts?.p ?? 1;
return {
async hash(password) {
const salt = randomBytes(16);
const keyLength = 32; // 256 bits
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
const hashBase64 = derivedKey.toString('base64');
const saltBase64 = salt.toString('base64');
return {
hash: hashBase64,
salt: saltBase64,
N,
r,
p
};
},
async verify(password, compare) {
const salt = Buffer.from(compare.salt, 'base64');
const keyLength = 32; // 256 bits
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
scrypt(
password,
salt,
keyLength,
{ N: compare.N, r: compare.r, p: compare.p },
(err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
}
);
});
return timingSafeEqual(derivedKey, Buffer.from(compare.hash, 'base64'));
}
};
}