@ultimat3/auth 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/package.json +37 -0
- package/src/adapter.ts +157 -0
- package/src/api-keys.ts +141 -0
- package/src/auth.ts +233 -0
- package/src/builtin-adapter.ts +286 -0
- package/src/errors.ts +229 -0
- package/src/guards.ts +43 -0
- package/src/id-token-fixture.ts +16 -0
- package/src/id-token.ts +161 -0
- package/src/index.ts +236 -0
- package/src/memory-adapter.ts +170 -0
- package/src/mfa.ts +209 -0
- package/src/oauth-cookie.ts +209 -0
- package/src/oauth-exchange.ts +244 -0
- package/src/oauth-login.ts +193 -0
- package/src/oauth-profile.ts +213 -0
- package/src/oauth.ts +168 -0
- package/src/password.ts +136 -0
- package/src/policy-bridge.ts +105 -0
- package/src/rate-limit.ts +104 -0
- package/src/session.ts +253 -0
- package/src/tables.ts +86 -0
- package/src/tokens.ts +51 -0
- package/src/verify.ts +127 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Single responsibility: session lifetime and the cookie that carries it. Two expiries are
|
|
2
|
+
// evaluated independently — `absoluteExpiresAt` is a ceiling activity can never push out, and
|
|
3
|
+
// `idleTtlMs` measures from `lastSeenAt` — because sliding-only expiry means a stolen session
|
|
4
|
+
// lives forever. `RequestLike`/`CookieJar` are structural on purpose: `@ultimat3/http` binds
|
|
5
|
+
// to them without this package importing it (same-tier packages must not depend on each other).
|
|
6
|
+
|
|
7
|
+
import type { Clock } from '@ultimat3/core';
|
|
8
|
+
import type { AuthSession, SessionStore } from './adapter';
|
|
9
|
+
import { sessionExpired, sessionUnknown } from './errors';
|
|
10
|
+
import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
|
|
11
|
+
|
|
12
|
+
export interface SessionPolicy {
|
|
13
|
+
/** Hard ceiling from creation. Never extended. */
|
|
14
|
+
readonly absoluteTtlMs: number;
|
|
15
|
+
/** Measured from `lastSeenAt`, refreshed on every verified request. */
|
|
16
|
+
readonly idleTtlMs: number;
|
|
17
|
+
readonly cookieName: string;
|
|
18
|
+
/** Mint a new session id whenever roles/scopes change — closes session fixation. */
|
|
19
|
+
readonly rotateOnPrivilegeChange: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_SESSION_POLICY: SessionPolicy = Object.freeze({
|
|
23
|
+
absoluteTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
24
|
+
idleTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
25
|
+
// `__Host-` is a browser-enforced contract: the cookie must be Secure, Path=/ and carry no
|
|
26
|
+
// Domain. A subdomain (or an XSS on one) therefore cannot overwrite it — session fixation.
|
|
27
|
+
cookieName: '__Host-x_session',
|
|
28
|
+
rotateOnPrivilegeChange: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export interface SessionRuntime {
|
|
32
|
+
readonly store: SessionStore;
|
|
33
|
+
readonly policy: SessionPolicy;
|
|
34
|
+
readonly clock: Clock;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RequestLike {
|
|
38
|
+
readonly headers: { get(name: string): string | null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CookieJar {
|
|
42
|
+
get(name: string): string | undefined;
|
|
43
|
+
set(name: string, value: string, attributes?: Readonly<Record<string, unknown>>): void;
|
|
44
|
+
delete(name: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SessionDevice {
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
readonly ip: string | null;
|
|
50
|
+
readonly userAgent: string | null;
|
|
51
|
+
readonly lastSeenAt: Date;
|
|
52
|
+
readonly createdAt: Date;
|
|
53
|
+
readonly current: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SessionExpiry {
|
|
57
|
+
readonly absoluteExpired: boolean;
|
|
58
|
+
readonly idleExpired: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface IssuedSession {
|
|
62
|
+
readonly session: AuthSession;
|
|
63
|
+
/** Shown once, set as a cookie, never stored. Only its SHA-256 reaches the row. */
|
|
64
|
+
readonly token: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CreateSessionInput {
|
|
68
|
+
readonly userId: string;
|
|
69
|
+
readonly ip?: string | null | undefined;
|
|
70
|
+
readonly userAgent?: string | null | undefined;
|
|
71
|
+
readonly mfaSatisfied?: boolean | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `<id>.<secret>`: the id is the row key, the secret is the half that is hashed. */
|
|
75
|
+
export function parseSessionToken(token: string): { id: string; secret: string } | null {
|
|
76
|
+
const dot = token.indexOf('.');
|
|
77
|
+
if (dot <= 0 || dot === token.length - 1) return null;
|
|
78
|
+
return { id: token.slice(0, dot), secret: token.slice(dot + 1) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function createSession(
|
|
82
|
+
runtime: SessionRuntime,
|
|
83
|
+
input: CreateSessionInput,
|
|
84
|
+
): Promise<IssuedSession> {
|
|
85
|
+
const now = runtime.clock.now();
|
|
86
|
+
const id = randomToken(12);
|
|
87
|
+
const secret = randomToken(32);
|
|
88
|
+
const session = await runtime.store.createSession({
|
|
89
|
+
id,
|
|
90
|
+
userId: input.userId,
|
|
91
|
+
tokenHash: sha256Hex(secret),
|
|
92
|
+
createdAt: now,
|
|
93
|
+
absoluteExpiresAt: new Date(now.getTime() + runtime.policy.absoluteTtlMs),
|
|
94
|
+
lastSeenAt: now,
|
|
95
|
+
ip: input.ip ?? null,
|
|
96
|
+
userAgent: input.userAgent ?? null,
|
|
97
|
+
mfaSatisfied: input.mfaSatisfied ?? false,
|
|
98
|
+
});
|
|
99
|
+
return { session, token: `${id}.${secret}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The two clocks are computed separately; neither can mask the other. */
|
|
103
|
+
export function sessionExpiry(
|
|
104
|
+
session: AuthSession,
|
|
105
|
+
policy: SessionPolicy,
|
|
106
|
+
now: Date,
|
|
107
|
+
): SessionExpiry {
|
|
108
|
+
return {
|
|
109
|
+
absoluteExpired: now.getTime() >= session.absoluteExpiresAt.getTime(),
|
|
110
|
+
idleExpired: now.getTime() - session.lastSeenAt.getTime() >= policy.idleTtlMs,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a cookie value to a live session, sliding the idle window forward. Throws
|
|
116
|
+
* `X_SESSION_EXPIRED` for either clock and `X_UNAUTHENTICATED` for anything unknown —
|
|
117
|
+
* a forged id and a deleted session are indistinguishable to the caller.
|
|
118
|
+
*/
|
|
119
|
+
export async function verifySession(
|
|
120
|
+
runtime: SessionRuntime,
|
|
121
|
+
token: string,
|
|
122
|
+
observed?: { readonly ip?: string | null | undefined; readonly userAgent?: string | null },
|
|
123
|
+
): Promise<AuthSession> {
|
|
124
|
+
const parsed = parseSessionToken(token);
|
|
125
|
+
if (parsed === null) throw sessionUnknown();
|
|
126
|
+
const session = await runtime.store.getSession(parsed.id);
|
|
127
|
+
if (session === null) throw sessionUnknown();
|
|
128
|
+
if (!timingSafeEqual(sha256Hex(parsed.secret), session.tokenHash)) throw sessionUnknown();
|
|
129
|
+
|
|
130
|
+
const now = runtime.clock.now();
|
|
131
|
+
const expiry = sessionExpiry(session, runtime.policy, now);
|
|
132
|
+
if (expiry.absoluteExpired || expiry.idleExpired) {
|
|
133
|
+
await runtime.store.deleteSession(session.id);
|
|
134
|
+
throw sessionExpired(expiry.absoluteExpired ? 'absolute' : 'idle', session.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const touched = await runtime.store.updateSession(session.id, {
|
|
138
|
+
lastSeenAt: now,
|
|
139
|
+
ip: observed?.ip ?? session.ip,
|
|
140
|
+
userAgent: observed?.userAgent ?? session.userAgent,
|
|
141
|
+
});
|
|
142
|
+
return touched ?? session;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Privilege change (role grant, MFA satisfied, password change) must not reuse the old id:
|
|
147
|
+
* whoever already holds the old cookie would inherit the new privileges.
|
|
148
|
+
*/
|
|
149
|
+
export async function rotateSession(
|
|
150
|
+
runtime: SessionRuntime,
|
|
151
|
+
session: AuthSession,
|
|
152
|
+
patch?: { readonly mfaSatisfied?: boolean | undefined },
|
|
153
|
+
): Promise<IssuedSession> {
|
|
154
|
+
await runtime.store.deleteSession(session.id);
|
|
155
|
+
return await createSession(runtime, {
|
|
156
|
+
userId: session.userId,
|
|
157
|
+
ip: session.ip,
|
|
158
|
+
userAgent: session.userAgent,
|
|
159
|
+
mfaSatisfied: patch?.mfaSatisfied ?? session.mfaSatisfied,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function revokeSession(runtime: SessionRuntime, sessionId: string): Promise<boolean> {
|
|
164
|
+
return await runtime.store.deleteSession(sessionId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function revokeOtherSessions(
|
|
168
|
+
runtime: SessionRuntime,
|
|
169
|
+
userId: string,
|
|
170
|
+
keepSessionId: string,
|
|
171
|
+
): Promise<number> {
|
|
172
|
+
return await runtime.store.deleteOtherSessions(userId, keepSessionId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What the "your devices" screen renders. Never includes the token hash. */
|
|
176
|
+
export async function listDevices(
|
|
177
|
+
runtime: SessionRuntime,
|
|
178
|
+
userId: string,
|
|
179
|
+
currentSessionId?: string,
|
|
180
|
+
): Promise<readonly SessionDevice[]> {
|
|
181
|
+
const sessions = await runtime.store.listSessions(userId);
|
|
182
|
+
return sessions.map((session) => ({
|
|
183
|
+
sessionId: session.id,
|
|
184
|
+
ip: session.ip,
|
|
185
|
+
userAgent: session.userAgent,
|
|
186
|
+
lastSeenAt: session.lastSeenAt,
|
|
187
|
+
createdAt: session.createdAt,
|
|
188
|
+
current: session.id === currentSessionId,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface SessionCookieOptions {
|
|
193
|
+
readonly name?: string | undefined;
|
|
194
|
+
readonly maxAgeSeconds?: number | undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Every attribute is load-bearing:
|
|
199
|
+
* - `HttpOnly` — script cannot read it, so an XSS cannot exfiltrate the session.
|
|
200
|
+
* - `Secure` — never sent over plaintext, so a network attacker cannot lift it.
|
|
201
|
+
* - `SameSite=Lax` — not attached to cross-site POSTs, which is CSRF's whole mechanism.
|
|
202
|
+
* - `Path=/`, no `Domain` — required by `__Host-`; a sibling subdomain cannot set or read it.
|
|
203
|
+
* - `Max-Age` — the client drops it at the absolute expiry, matching the server's ceiling.
|
|
204
|
+
*/
|
|
205
|
+
export function sessionCookie(
|
|
206
|
+
token: string,
|
|
207
|
+
policy: SessionPolicy,
|
|
208
|
+
options?: SessionCookieOptions,
|
|
209
|
+
): string {
|
|
210
|
+
const name = options?.name ?? policy.cookieName;
|
|
211
|
+
const maxAge = options?.maxAgeSeconds ?? Math.floor(policy.absoluteTtlMs / 1000);
|
|
212
|
+
return `${name}=${token}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Same attributes, empty value, `Max-Age=0` — a mismatched attribute set leaves a live twin. */
|
|
216
|
+
export function clearSessionCookie(policy: SessionPolicy, name?: string): string {
|
|
217
|
+
return `${name ?? policy.cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The one `Cookie:` parser in this package — the oauth handshake reads through it too, so it never
|
|
222
|
+
* throws: a missing or unreadable cookie is `null` or the raw value, never an exception.
|
|
223
|
+
*/
|
|
224
|
+
export function readCookie(request: RequestLike, name: string): string | null {
|
|
225
|
+
const header = request.headers.get('cookie');
|
|
226
|
+
if (header === null) return null;
|
|
227
|
+
for (const part of header.split(';')) {
|
|
228
|
+
const equals = part.indexOf('=');
|
|
229
|
+
if (equals < 0) continue;
|
|
230
|
+
if (part.slice(0, equals).trim() !== name) continue;
|
|
231
|
+
return decodeCookieValue(part.slice(equals + 1).trim());
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A `Cookie:` header is attacker-controlled, and `decodeURIComponent('%')` throws a bare
|
|
238
|
+
* `URIError` — which escapes every coded path that reads through here: an OAuth callback would
|
|
239
|
+
* answer 500 instead of `X_OAUTH_STATE_INVALID`. The raw value is returned instead, so the
|
|
240
|
+
* caller's own rejection stays the readable failure. Nothing is loosened by it: a raw value is
|
|
241
|
+
* still checked against a signature or a stored hash, and neither matches a mangled one.
|
|
242
|
+
*/
|
|
243
|
+
function decodeCookieValue(raw: string): string {
|
|
244
|
+
try {
|
|
245
|
+
return decodeURIComponent(raw);
|
|
246
|
+
} catch {
|
|
247
|
+
return raw;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function readSessionCookie(request: RequestLike, policy: SessionPolicy): string | null {
|
|
252
|
+
return readCookie(request, policy.cookieName);
|
|
253
|
+
}
|
package/src/tables.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Single responsibility: the DDL `BuiltinAdapter` expects. Exported as plain strings so an app
|
|
2
|
+
// can paste them into a migration and read exactly what auth stores — no column holds a
|
|
3
|
+
// plaintext secret, and that is meant to be verifiable by reading, not by trusting.
|
|
4
|
+
|
|
5
|
+
export const X_USERS_TABLE = `create table if not exists x_users (
|
|
6
|
+
id uuid primary key,
|
|
7
|
+
email text not null unique,
|
|
8
|
+
email_verified_at timestamptz,
|
|
9
|
+
password_hash text,
|
|
10
|
+
org_id uuid,
|
|
11
|
+
roles text[] not null default '{}',
|
|
12
|
+
permissions text[] not null default '{}',
|
|
13
|
+
mfa_secret text,
|
|
14
|
+
recovery_code_hashes text[] not null default '{}',
|
|
15
|
+
disabled_at timestamptz,
|
|
16
|
+
created_at timestamptz not null default now()
|
|
17
|
+
)`;
|
|
18
|
+
|
|
19
|
+
// `id` is the public half of the cookie; `token_hash` is sha256 of the secret half.
|
|
20
|
+
export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
|
|
21
|
+
id text primary key,
|
|
22
|
+
user_id uuid not null references x_users (id) on delete cascade,
|
|
23
|
+
token_hash text not null,
|
|
24
|
+
created_at timestamptz not null default now(),
|
|
25
|
+
absolute_expires_at timestamptz not null,
|
|
26
|
+
last_seen_at timestamptz not null default now(),
|
|
27
|
+
ip text,
|
|
28
|
+
user_agent text,
|
|
29
|
+
mfa_satisfied boolean not null default false
|
|
30
|
+
);
|
|
31
|
+
create index if not exists x_sessions_user_id_idx on x_sessions (user_id);
|
|
32
|
+
create index if not exists x_sessions_absolute_expires_at_idx on x_sessions (absolute_expires_at)`;
|
|
33
|
+
|
|
34
|
+
export const X_ACCOUNTS_TABLE = `create table if not exists x_accounts (
|
|
35
|
+
id uuid primary key,
|
|
36
|
+
user_id uuid not null references x_users (id) on delete cascade,
|
|
37
|
+
provider text not null,
|
|
38
|
+
provider_account_id text not null,
|
|
39
|
+
access_token text,
|
|
40
|
+
refresh_token text,
|
|
41
|
+
expires_at timestamptz,
|
|
42
|
+
created_at timestamptz not null default now(),
|
|
43
|
+
unique (provider, provider_account_id)
|
|
44
|
+
)`;
|
|
45
|
+
|
|
46
|
+
// One live token per (purpose, identifier): issuing a new one overwrites the old.
|
|
47
|
+
export const X_VERIFICATIONS_TABLE = `create table if not exists x_verifications (
|
|
48
|
+
id text primary key,
|
|
49
|
+
purpose text not null,
|
|
50
|
+
identifier text not null,
|
|
51
|
+
token_hash text not null,
|
|
52
|
+
expires_at timestamptz not null,
|
|
53
|
+
consumed_at timestamptz,
|
|
54
|
+
created_at timestamptz not null default now(),
|
|
55
|
+
unique (purpose, identifier)
|
|
56
|
+
)`;
|
|
57
|
+
|
|
58
|
+
export const X_API_KEYS_TABLE = `create table if not exists x_api_keys (
|
|
59
|
+
id text primary key,
|
|
60
|
+
prefix text not null unique,
|
|
61
|
+
key_hash text not null,
|
|
62
|
+
user_id uuid,
|
|
63
|
+
org_id uuid,
|
|
64
|
+
scopes text[] not null default '{}',
|
|
65
|
+
last_used_at timestamptz,
|
|
66
|
+
expires_at timestamptz,
|
|
67
|
+
revoked_at timestamptz,
|
|
68
|
+
created_at timestamptz not null default now()
|
|
69
|
+
)`;
|
|
70
|
+
|
|
71
|
+
/** Ordered by foreign-key dependency — run them top to bottom. */
|
|
72
|
+
export const AUTH_TABLES: readonly string[] = Object.freeze([
|
|
73
|
+
X_USERS_TABLE,
|
|
74
|
+
X_SESSIONS_TABLE,
|
|
75
|
+
X_ACCOUNTS_TABLE,
|
|
76
|
+
X_VERIFICATIONS_TABLE,
|
|
77
|
+
X_API_KEYS_TABLE,
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
export const AUTH_TABLE_NAMES: readonly string[] = Object.freeze([
|
|
81
|
+
'x_users',
|
|
82
|
+
'x_sessions',
|
|
83
|
+
'x_accounts',
|
|
84
|
+
'x_verifications',
|
|
85
|
+
'x_api_keys',
|
|
86
|
+
]);
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Single responsibility: the secret primitives every other file in this package shares —
|
|
2
|
+
// CSPRNG tokens, SHA-256 hashing and a comparison whose duration does not depend on where
|
|
3
|
+
// two strings first differ. Centralised so no call site can quietly reach for `===` on a
|
|
4
|
+
// secret, which leaks the shared prefix length one request at a time.
|
|
5
|
+
|
|
6
|
+
const BASE64URL_UNSAFE = /[+/=]/g;
|
|
7
|
+
const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
|
|
8
|
+
|
|
9
|
+
export function randomBytes(length: number): Uint8Array {
|
|
10
|
+
const bytes = new Uint8Array(length);
|
|
11
|
+
crypto.getRandomValues(bytes);
|
|
12
|
+
return bytes;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function base64Url(bytes: Uint8Array): string {
|
|
16
|
+
let binary = '';
|
|
17
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
18
|
+
return btoa(binary).replace(BASE64URL_UNSAFE, (char) => BASE64URL_REPLACEMENTS[char] ?? '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 32 bytes -> 43 base64url chars. Opaque by construction: it encodes nothing about the user. */
|
|
22
|
+
export function randomToken(byteLength = 32): string {
|
|
23
|
+
return base64Url(randomBytes(byteLength));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sha256Hex(value: string): string {
|
|
27
|
+
return new Bun.CryptoHasher('sha256').update(value).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sha256Bytes(value: string): Uint8Array {
|
|
31
|
+
return Uint8Array.from(new Bun.CryptoHasher('sha256').update(value).digest());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Length is compared first and non-constant-time on purpose: every secret this package
|
|
36
|
+
* compares is a fixed-width hash or token, so the length carries no information, and the
|
|
37
|
+
* XOR accumulator below is what has to be branch-free.
|
|
38
|
+
*/
|
|
39
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
40
|
+
if (a.length !== b.length) return false;
|
|
41
|
+
let diff = 0;
|
|
42
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
43
|
+
diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
44
|
+
}
|
|
45
|
+
return diff === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Hash-then-compare. The plaintext never has to be held next to the stored value. */
|
|
49
|
+
export function matchesHash(plaintext: string, storedHash: string): boolean {
|
|
50
|
+
return timingSafeEqual(sha256Hex(plaintext), storedHash);
|
|
51
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Single responsibility: email verification and password reset — the two flows where a link in
|
|
2
|
+
// an inbox is a credential. Tokens are single-use, expiring, stored hashed and compared in
|
|
3
|
+
// constant time. Mail leaves through an injected `MailSender` port: the app wires
|
|
4
|
+
// `@ultimat3/mail`'s `send` into it, because auth must not depend on a sideways tier-3 package.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import type { AuthVerification, VerificationStore } from './adapter';
|
|
8
|
+
import { AuthError } from './errors';
|
|
9
|
+
import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `locale` is required, not optional: an inferred locale is how a user gets a security email
|
|
13
|
+
* in a language they cannot read. `@ultimat3/mail`'s `send` is the production binding.
|
|
14
|
+
*/
|
|
15
|
+
export interface MailSender {
|
|
16
|
+
send(
|
|
17
|
+
template: string,
|
|
18
|
+
to: string,
|
|
19
|
+
data: Readonly<Record<string, unknown>>,
|
|
20
|
+
locale: string,
|
|
21
|
+
): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const VERIFICATION_PURPOSES = ['email-verify', 'password-reset'] as const;
|
|
25
|
+
|
|
26
|
+
export type VerificationPurpose = (typeof VERIFICATION_PURPOSES)[number];
|
|
27
|
+
|
|
28
|
+
/** Catalog keys, not copy. The template body lives in the app's i18n catalog. */
|
|
29
|
+
export const VERIFICATION_TEMPLATES: Readonly<Record<VerificationPurpose, string>> = {
|
|
30
|
+
'email-verify': 'auth.email-verify',
|
|
31
|
+
'password-reset': 'auth.password-reset',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Short by default: a reset link is a password, and a password that lives a day is a liability. */
|
|
35
|
+
export const DEFAULT_VERIFICATION_TTL_MS: Readonly<Record<VerificationPurpose, number>> = {
|
|
36
|
+
'email-verify': 24 * 60 * 60 * 1000,
|
|
37
|
+
'password-reset': 60 * 60 * 1000,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface VerificationRuntime {
|
|
41
|
+
readonly store: VerificationStore;
|
|
42
|
+
readonly clock: Clock;
|
|
43
|
+
readonly mail: MailSender;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IssueVerificationInput {
|
|
47
|
+
readonly purpose: VerificationPurpose;
|
|
48
|
+
/** The email address. Also the store key — one live token per purpose per address. */
|
|
49
|
+
readonly identifier: string;
|
|
50
|
+
readonly locale: string;
|
|
51
|
+
readonly ttlMs?: number | undefined;
|
|
52
|
+
/** Builds the URL that carries the token. Defaults to the token alone. */
|
|
53
|
+
readonly link?: ((token: string) => string) | undefined;
|
|
54
|
+
readonly data?: Readonly<Record<string, unknown>> | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface IssuedVerification {
|
|
58
|
+
/** Returned so a test or a CLI can assert on it. Production only ever mails it. */
|
|
59
|
+
readonly token: string;
|
|
60
|
+
readonly expiresAt: Date;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function issueVerification(
|
|
64
|
+
runtime: VerificationRuntime,
|
|
65
|
+
input: IssueVerificationInput,
|
|
66
|
+
): Promise<IssuedVerification> {
|
|
67
|
+
const now = runtime.clock.now();
|
|
68
|
+
const token = randomToken(32);
|
|
69
|
+
const ttl = input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[input.purpose];
|
|
70
|
+
const expiresAt = new Date(now.getTime() + ttl);
|
|
71
|
+
await runtime.store.putVerification({
|
|
72
|
+
id: randomToken(12),
|
|
73
|
+
purpose: input.purpose,
|
|
74
|
+
identifier: input.identifier,
|
|
75
|
+
tokenHash: sha256Hex(token),
|
|
76
|
+
expiresAt,
|
|
77
|
+
consumedAt: null,
|
|
78
|
+
createdAt: now,
|
|
79
|
+
});
|
|
80
|
+
await runtime.mail.send(
|
|
81
|
+
VERIFICATION_TEMPLATES[input.purpose],
|
|
82
|
+
input.identifier,
|
|
83
|
+
{
|
|
84
|
+
...(input.data ?? {}),
|
|
85
|
+
link: input.link?.(token) ?? token,
|
|
86
|
+
expiresAt: expiresAt.toISOString(),
|
|
87
|
+
},
|
|
88
|
+
input.locale,
|
|
89
|
+
);
|
|
90
|
+
return { token, expiresAt };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One error for "no such token", "already used", "expired" and "wrong token". Distinguishing
|
|
95
|
+
* them tells an attacker which address has a live reset in flight.
|
|
96
|
+
*/
|
|
97
|
+
const verificationInvalid = (purpose: string): AuthError =>
|
|
98
|
+
new AuthError({
|
|
99
|
+
code: 'X_UNAUTHENTICATED',
|
|
100
|
+
cause: `the ${purpose} token is unknown, already used, expired or does not match`,
|
|
101
|
+
fix: `request a new link — ${purpose} tokens are single-use`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export interface ConsumeVerificationInput {
|
|
105
|
+
readonly purpose: VerificationPurpose;
|
|
106
|
+
readonly identifier: string;
|
|
107
|
+
readonly token: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The store consumes the row atomically, so a second redemption finds nothing even if it
|
|
112
|
+
* races the first. The hash comparison happens after, on the row we already own.
|
|
113
|
+
*/
|
|
114
|
+
export async function consumeVerification(
|
|
115
|
+
runtime: VerificationRuntime,
|
|
116
|
+
input: ConsumeVerificationInput,
|
|
117
|
+
): Promise<AuthVerification> {
|
|
118
|
+
const record = await runtime.store.takeVerification(input.purpose, input.identifier);
|
|
119
|
+
if (record === null) throw verificationInvalid(input.purpose);
|
|
120
|
+
if (runtime.clock.now().getTime() >= record.expiresAt.getTime()) {
|
|
121
|
+
throw verificationInvalid(input.purpose);
|
|
122
|
+
}
|
|
123
|
+
if (!timingSafeEqual(sha256Hex(input.token), record.tokenHash)) {
|
|
124
|
+
throw verificationInvalid(input.purpose);
|
|
125
|
+
}
|
|
126
|
+
return record;
|
|
127
|
+
}
|