@shtora/auth 0.0.0-stage → 0.1.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.
@@ -0,0 +1,51 @@
1
+ import type { AuthActionTokenStore, AuthRateLimiter, AuthRequestRateLimiter, SessionStore } from "./types.js";
2
+ interface D1Result<Row> {
3
+ results?: Row[];
4
+ success?: boolean;
5
+ meta?: {
6
+ changes?: number;
7
+ };
8
+ }
9
+ type D1Value = string | number | boolean | null | ArrayBuffer | ArrayBufferView;
10
+ interface D1PreparedStatement {
11
+ bind(...values: D1Value[]): D1PreparedStatement;
12
+ first<Row = Record<string, unknown>>(): Promise<Row | null>;
13
+ all<Row = Record<string, unknown>>(): Promise<D1Result<Row>>;
14
+ run(): Promise<D1Result<never>>;
15
+ }
16
+ /** The subset of Cloudflare's D1 binding used by these adapters. */
17
+ export interface D1Database {
18
+ prepare(query: string): D1PreparedStatement;
19
+ batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
20
+ }
21
+ export type D1ActionTokenStore = AuthActionTokenStore;
22
+ export declare function createD1ActionTokenStore(db: D1Database): D1ActionTokenStore;
23
+ export declare function createD1SessionStore(db: D1Database): SessionStore;
24
+ export interface D1RateLimitWindow {
25
+ limit: number;
26
+ windowSeconds: number;
27
+ }
28
+ export interface D1RateLimiterOptions {
29
+ email: D1RateLimitWindow;
30
+ ip: D1RateLimitWindow;
31
+ /** Epoch milliseconds; injectable to make window boundaries deterministic in tests. */
32
+ now?: () => number;
33
+ }
34
+ export declare function createD1RateLimiter(db: D1Database, options: D1RateLimiterOptions): AuthRateLimiter;
35
+ export interface D1RequestRateLimiterOptions {
36
+ limit: number;
37
+ windowSeconds: number;
38
+ /** Epoch milliseconds; injectable to make window boundaries deterministic in tests. */
39
+ now?: () => number;
40
+ }
41
+ export declare function createD1RequestRateLimiter(db: D1Database, options: D1RequestRateLimiterOptions): AuthRequestRateLimiter;
42
+ export interface D1AuthCleanupOptions {
43
+ /** Epoch milliseconds; injectable to make cleanup deterministic in tests. */
44
+ nowMs?: number;
45
+ /** Longest configured rate-limit window, used to retain active counters. */
46
+ rateLimitMaxWindowSeconds: number;
47
+ /** Rows removed per table per call. Defaults to 500; capped at 1000. */
48
+ batchSize?: number;
49
+ }
50
+ export declare function cleanupD1AuthState(db: D1Database, options: D1AuthCleanupOptions): Promise<void>;
51
+ export {};
@@ -0,0 +1,156 @@
1
+ export function createD1ActionTokenStore(db) {
2
+ return {
3
+ async create({ purpose, userId, tokenHash, expiresAtMs }) {
4
+ await db.batch([
5
+ db.prepare("DELETE FROM shtora_action_tokens WHERE purpose = ? AND user_id = ?").bind(purpose, userId),
6
+ db.prepare("INSERT INTO shtora_action_tokens (purpose, user_id, token_hash, expires_at_ms) VALUES (?, ?, ?, ?)").bind(purpose, userId, tokenHash, expiresAtMs),
7
+ ]);
8
+ },
9
+ async consume({ purpose, tokenHash, nowMs }) {
10
+ const row = await db.prepare("DELETE FROM shtora_action_tokens WHERE purpose = ? AND token_hash = ? AND expires_at_ms > ? RETURNING user_id").bind(purpose, tokenHash, nowMs).first();
11
+ return row ? { userId: row.user_id } : null;
12
+ },
13
+ async deleteExpired({ nowMs, limit }) {
14
+ validateCleanupInput(nowMs, limit);
15
+ await db.prepare("DELETE FROM shtora_action_tokens WHERE rowid IN " +
16
+ "(SELECT rowid FROM shtora_action_tokens WHERE expires_at_ms <= ? ORDER BY expires_at_ms LIMIT ?)").bind(nowMs, limit).run();
17
+ },
18
+ };
19
+ }
20
+ export function createD1SessionStore(db) {
21
+ return {
22
+ async create(session) {
23
+ await db.prepare("INSERT INTO shtora_sessions (id, token_hash, user_id, created_at_ms, expires_at_ms) VALUES (?, ?, ?, ?, ?)").bind(session.id, session.tokenHash, session.userId, session.createdAtMs, session.expiresAtMs).run();
24
+ },
25
+ async findByTokenHash(tokenHash) {
26
+ const row = await db.prepare("SELECT id, token_hash, user_id, created_at_ms, expires_at_ms FROM shtora_sessions WHERE token_hash = ? LIMIT 1").bind(tokenHash).first();
27
+ return row ? toStoredSession(row) : null;
28
+ },
29
+ async deleteByTokenHash(tokenHash) {
30
+ await db.prepare("DELETE FROM shtora_sessions WHERE token_hash = ?").bind(tokenHash).run();
31
+ },
32
+ async listByUserId(userId) {
33
+ const result = await db.prepare("SELECT id, token_hash, user_id, created_at_ms, expires_at_ms FROM shtora_sessions " +
34
+ "WHERE user_id = ? AND expires_at_ms > ? ORDER BY created_at_ms, id").bind(userId, Date.now()).all();
35
+ return (result.results ?? []).map(toStoredSession);
36
+ },
37
+ async deleteByIdForUser(id, userId) {
38
+ await db.prepare("DELETE FROM shtora_sessions WHERE id = ? AND user_id = ?").bind(id, userId).run();
39
+ },
40
+ async deleteByUserId(userId) {
41
+ await db.prepare("DELETE FROM shtora_sessions WHERE user_id = ?").bind(userId).run();
42
+ },
43
+ async deleteExpired({ nowMs, limit }) {
44
+ validateCleanupInput(nowMs, limit);
45
+ await db.prepare("DELETE FROM shtora_sessions WHERE rowid IN " +
46
+ "(SELECT rowid FROM shtora_sessions WHERE expires_at_ms <= ? ORDER BY expires_at_ms LIMIT ?)").bind(nowMs, limit).run();
47
+ },
48
+ };
49
+ }
50
+ function toStoredSession(row) {
51
+ return {
52
+ id: row.id,
53
+ tokenHash: row.token_hash,
54
+ userId: row.user_id,
55
+ createdAtMs: row.created_at_ms,
56
+ expiresAtMs: row.expires_at_ms,
57
+ };
58
+ }
59
+ export function createD1RateLimiter(db, options) {
60
+ validateWindow("email", options.email);
61
+ validateWindow("ip", options.ip);
62
+ const now = options.now ?? Date.now;
63
+ return {
64
+ async check({ action, email, ip }) {
65
+ const dimensions = [
66
+ { name: "email", raw: email, config: options.email },
67
+ { name: "ip", raw: ip, config: options.ip },
68
+ ];
69
+ const digests = await Promise.all(dimensions.map(({ name, raw }) => hashKey(`${action}\0${name}\0${raw}`)));
70
+ const timestamp = now();
71
+ const windows = dimensions.map(({ config }) => Math.floor(timestamp / (config.windowSeconds * 1000)) * config.windowSeconds * 1000);
72
+ const statements = dimensions.map((_, index) => db.prepare("INSERT INTO shtora_rate_limits (key_hash, window_start_ms, count) VALUES (?, ?, 1) " +
73
+ "ON CONFLICT(key_hash) DO UPDATE SET " +
74
+ "count = CASE WHEN shtora_rate_limits.window_start_ms < excluded.window_start_ms THEN 1 ELSE shtora_rate_limits.count + 1 END, " +
75
+ "window_start_ms = MAX(shtora_rate_limits.window_start_ms, excluded.window_start_ms) RETURNING count, window_start_ms").bind(digests[index], windows[index]));
76
+ const results = await db.batch(statements);
77
+ let retryAfterSeconds = 0;
78
+ for (let index = 0; index < dimensions.length; index += 1) {
79
+ const row = results[index]?.results?.[0];
80
+ if (!row || typeof row.count !== "number" || typeof row.window_start_ms !== "number") {
81
+ throw new Error("D1 rate limiter did not return a counter value");
82
+ }
83
+ const dimension = dimensions[index];
84
+ if (row.count > dimension.config.limit) {
85
+ const durationMs = dimension.config.windowSeconds * 1000;
86
+ const remainingMs = row.window_start_ms + durationMs - timestamp;
87
+ retryAfterSeconds = Math.max(retryAfterSeconds, Math.ceil(remainingMs / 1000), 1);
88
+ }
89
+ }
90
+ return retryAfterSeconds > 0
91
+ ? { allowed: false, retryAfterSeconds }
92
+ : { allowed: true };
93
+ },
94
+ };
95
+ }
96
+ function validateWindow(name, value) {
97
+ if (!Number.isSafeInteger(value.limit) || value.limit < 1) {
98
+ throw new RangeError(`${name} rate limit must be a positive safe integer`);
99
+ }
100
+ if (!Number.isSafeInteger(value.windowSeconds) || value.windowSeconds < 1 ||
101
+ value.windowSeconds > Math.floor(Number.MAX_SAFE_INTEGER / 1000)) {
102
+ throw new RangeError(`${name} rate limit windowSeconds must be a positive safe integer whose milliseconds are safe`);
103
+ }
104
+ }
105
+ export function createD1RequestRateLimiter(db, options) {
106
+ validateWindow("request", options);
107
+ const now = options.now ?? Date.now;
108
+ const durationMs = options.windowSeconds * 1000;
109
+ return {
110
+ async check({ action, ip }) {
111
+ const timestamp = now();
112
+ const windowStart = Math.floor(timestamp / durationMs) * durationMs;
113
+ const row = await db.prepare("INSERT INTO shtora_rate_limits (key_hash, window_start_ms, count) VALUES (?, ?, 1) " +
114
+ "ON CONFLICT(key_hash) DO UPDATE SET " +
115
+ "count = CASE WHEN shtora_rate_limits.window_start_ms < excluded.window_start_ms THEN 1 ELSE shtora_rate_limits.count + 1 END, " +
116
+ "window_start_ms = MAX(shtora_rate_limits.window_start_ms, excluded.window_start_ms) RETURNING count, window_start_ms").bind(await hashKey(`${action}\0request\0${ip}`), windowStart).first();
117
+ if (!row || typeof row.count !== "number" || typeof row.window_start_ms !== "number") {
118
+ throw new Error("D1 request rate limiter did not return a counter value");
119
+ }
120
+ if (row.count <= options.limit)
121
+ return { allowed: true };
122
+ const remainingMs = row.window_start_ms + durationMs - timestamp;
123
+ return { allowed: false, retryAfterSeconds: Math.max(Math.ceil(remainingMs / 1000), 1) };
124
+ },
125
+ };
126
+ }
127
+ function validateCleanupInput(nowMs, limit) {
128
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0)
129
+ throw new RangeError("nowMs must be a non-negative safe integer");
130
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
131
+ throw new RangeError("limit must be between 1 and 1000");
132
+ }
133
+ }
134
+ export async function cleanupD1AuthState(db, options) {
135
+ const nowMs = options.nowMs ?? Date.now();
136
+ const batchSize = options.batchSize ?? 500;
137
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0)
138
+ throw new RangeError("nowMs must be a non-negative safe integer");
139
+ validateWindow("rate-limit cleanup", { limit: 1, windowSeconds: options.rateLimitMaxWindowSeconds });
140
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 1000) {
141
+ throw new RangeError("batchSize must be between 1 and 1000");
142
+ }
143
+ const rateLimitCutoff = nowMs - options.rateLimitMaxWindowSeconds * 1000;
144
+ await db.batch([
145
+ db.prepare("DELETE FROM shtora_sessions WHERE rowid IN " +
146
+ "(SELECT rowid FROM shtora_sessions WHERE expires_at_ms <= ? ORDER BY expires_at_ms LIMIT ?)").bind(nowMs, batchSize),
147
+ db.prepare("DELETE FROM shtora_action_tokens WHERE rowid IN " +
148
+ "(SELECT rowid FROM shtora_action_tokens WHERE expires_at_ms <= ? ORDER BY expires_at_ms LIMIT ?)").bind(nowMs, batchSize),
149
+ db.prepare("DELETE FROM shtora_rate_limits WHERE rowid IN " +
150
+ "(SELECT rowid FROM shtora_rate_limits WHERE window_start_ms < ? ORDER BY window_start_ms LIMIT ?)").bind(rateLimitCutoff, batchSize),
151
+ ]);
152
+ }
153
+ async function hashKey(value) {
154
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
155
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
156
+ }
@@ -0,0 +1,3 @@
1
+ export declare function readSessionToken(cookieHeader: string | null, cookieName: string): string | null;
2
+ export declare function sessionCookie(token: string, cookieName: string, ttlSeconds: number, now: number): string;
3
+ export declare function clearedSessionCookie(cookieName: string): string;
@@ -0,0 +1,25 @@
1
+ const tokenPattern = /^[a-f0-9]{64}$/;
2
+ export function readSessionToken(cookieHeader, cookieName) {
3
+ if (!cookieHeader || cookieHeader.length > 16_384)
4
+ return null;
5
+ let token = null;
6
+ for (const part of cookieHeader.split(";")) {
7
+ const separator = part.indexOf("=");
8
+ if (separator < 0 || part.slice(0, separator).trim() !== cookieName)
9
+ continue;
10
+ if (token !== null)
11
+ return null;
12
+ const value = part.slice(separator + 1).trim();
13
+ if (!tokenPattern.test(value))
14
+ return null;
15
+ token = value;
16
+ }
17
+ return token;
18
+ }
19
+ export function sessionCookie(token, cookieName, ttlSeconds, now) {
20
+ const expires = new Date(now + ttlSeconds * 1000).toUTCString();
21
+ return `${cookieName}=${token}; Max-Age=${ttlSeconds}; Expires=${expires}; Path=/; HttpOnly; Secure; SameSite=Lax`;
22
+ }
23
+ export function clearedSessionCookie(cookieName) {
24
+ return `${cookieName}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax`;
25
+ }
@@ -0,0 +1,2 @@
1
+ export declare function createSessionToken(): string;
2
+ export declare function hashSessionToken(token: string): Promise<string>;
package/dist/crypto.js ADDED
@@ -0,0 +1,10 @@
1
+ const encoder = new TextEncoder();
2
+ export function createSessionToken() {
3
+ const bytes = new Uint8Array(32);
4
+ globalThis.crypto.getRandomValues(bytes);
5
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
6
+ }
7
+ export async function hashSessionToken(token) {
8
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", encoder.encode(token));
9
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
10
+ }
@@ -0,0 +1,2 @@
1
+ export { createAuth } from "./auth.js";
2
+ export type { AccessPolicy, AuthActionTokenDelivery, AuthActionTokenPurpose, AuthActionTokenStore, AuthApi, AuthenticatedUser, AuthOptions, AuthRateLimiter, AuthRequestRateLimitAction, AuthRequestRateLimiter, AuthUser, CredentialVerifier, EmailVerificationOptions, ExpiredRecordStore, PasswordResetOptions, SessionStore, StoredSession, UserStore, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createAuth } from "./auth.js";
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { AuthApi, AuthRequestRateLimiter, AuthUser, ExpiredRecordStore } from "./types.js";
2
+ export interface OidcTransaction {
3
+ readonly state: string;
4
+ readonly codeVerifier: string;
5
+ readonly nonce: string;
6
+ readonly expiresAtMs: number;
7
+ }
8
+ /** Stores must consume atomically and delete at most the requested number of expired records per cleanup call. */
9
+ export interface OidcTransactionStore extends ExpiredRecordStore {
10
+ create(transaction: OidcTransaction): Promise<void>;
11
+ consume(input: {
12
+ state: string;
13
+ nowMs: number;
14
+ }): Promise<OidcTransaction | null>;
15
+ }
16
+ export interface OidcHandler {
17
+ (request: Request): Promise<Response>;
18
+ cleanupExpired(): Promise<void>;
19
+ }
20
+ export interface OidcConfig<User extends AuthUser> {
21
+ readonly issuer: string;
22
+ readonly clientId: string;
23
+ readonly clientSecret?: string;
24
+ /** Absolute, pre-registered callback URL. */
25
+ readonly callbackUrl: string;
26
+ /** Fixed same-origin path after success, e.g. "/account". */
27
+ readonly successPath: string;
28
+ readonly transactions: OidcTransactionStore;
29
+ readonly resolveUser: (identity: {
30
+ issuer: string;
31
+ subject: string;
32
+ }) => Promise<User | null>;
33
+ readonly auth: Pick<AuthApi, "createSessionResponse">;
34
+ readonly resolveClientIp: (request: Request) => string | null | Promise<string | null>;
35
+ readonly rateLimiter: AuthRequestRateLimiter;
36
+ readonly transactionTtlSeconds?: number;
37
+ readonly scope?: string;
38
+ readonly onError?: (input: {
39
+ operation: "start" | "callback" | "cleanup";
40
+ error: unknown;
41
+ }) => void | Promise<void>;
42
+ }
43
+ /** Creates a Web-Request handler for GET /start and GET /callback. */
44
+ export declare function createOidc<User extends AuthUser>(options: OidcConfig<User>): OidcHandler;
package/dist/oidc.js ADDED
@@ -0,0 +1,192 @@
1
+ import * as oidc from "openid-client";
2
+ const cookieName = "__Host-shtora.oidc-state";
3
+ const defaultTtlSeconds = 600;
4
+ /** Creates a Web-Request handler for GET /start and GET /callback. */
5
+ export function createOidc(options) {
6
+ const issuerUrl = new URL(options.issuer);
7
+ const callbackUrl = new URL(options.callbackUrl);
8
+ if (issuerUrl.protocol !== "https:" || issuerUrl.username || issuerUrl.password || issuerUrl.search || issuerUrl.hash) {
9
+ throw new TypeError("issuer must be an HTTPS issuer URL without credentials, query, or fragment");
10
+ }
11
+ if (callbackUrl.protocol !== "https:" || callbackUrl.username || callbackUrl.password || callbackUrl.search || callbackUrl.hash) {
12
+ throw new TypeError("callbackUrl must be an HTTPS URL without credentials, query, or fragment");
13
+ }
14
+ if (!options.successPath.startsWith("/") || options.successPath.startsWith("//") || /[?#\\]/.test(options.successPath)) {
15
+ throw new TypeError("successPath must be a fixed absolute same-origin path");
16
+ }
17
+ const ttl = options.transactionTtlSeconds ?? defaultTtlSeconds;
18
+ if (!Number.isInteger(ttl) || ttl < 1 || ttl > 3600)
19
+ throw new RangeError("transactionTtlSeconds must be between 1 and 3600");
20
+ let configPromise;
21
+ const getConfig = async () => {
22
+ configPromise ??= oidc.discovery(issuerUrl, options.clientId, options.clientSecret);
23
+ try {
24
+ return await configPromise;
25
+ }
26
+ catch (error) {
27
+ configPromise = undefined;
28
+ throw error;
29
+ }
30
+ };
31
+ const cookie = (state, maxAge) => `${cookieName}=${state}; Path=/; Max-Age=${maxAge}; Secure; HttpOnly; SameSite=Lax`;
32
+ const clearCookie = `${cookieName}=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax`;
33
+ const readStateCookie = (request) => {
34
+ let found = null;
35
+ for (const value of (request.headers.get("cookie") ?? "").split(";")) {
36
+ const pair = value.trim();
37
+ const equals = pair.indexOf("=");
38
+ if (equals < 0 || pair.slice(0, equals) !== cookieName)
39
+ continue;
40
+ if (found !== null)
41
+ return null;
42
+ found = pair.slice(equals + 1);
43
+ }
44
+ return found;
45
+ };
46
+ const callbackRequestUrl = (request) => {
47
+ const host = request.headers.get("host");
48
+ if (host) {
49
+ try {
50
+ const requestAuthority = new URL(`https://${host}`);
51
+ if (requestAuthority.host !== callbackUrl.host || requestAuthority.username || requestAuthority.password ||
52
+ requestAuthority.pathname !== "/" || requestAuthority.search || requestAuthority.hash)
53
+ return null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ const incoming = new URL(request.url);
60
+ if (incoming.pathname !== callbackUrl.pathname)
61
+ return null;
62
+ const callback = new URL(options.callbackUrl);
63
+ const allowed = new Set(["code", "state", "iss", "error", "error_description", "error_uri", "session_state"]);
64
+ for (const key of new Set(incoming.searchParams.keys())) {
65
+ const values = incoming.searchParams.getAll(key);
66
+ if (!allowed.has(key) || values.length !== 1)
67
+ return null;
68
+ callback.searchParams.set(key, values[0]);
69
+ }
70
+ return callback;
71
+ };
72
+ const report = async (operation, error) => {
73
+ try {
74
+ await options.onError?.({ operation, error });
75
+ }
76
+ catch { /* observer failures do not affect the response */ }
77
+ };
78
+ const failure = (headers, status = 400) => new Response("Sign-in failed", {
79
+ status,
80
+ headers: { "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8", ...Object.fromEntries(new Headers(headers)) },
81
+ });
82
+ const rateLimited = async (action, request, headers) => {
83
+ try {
84
+ const ip = await options.resolveClientIp(request);
85
+ const decision = ip ? await options.rateLimiter.check({ action, ip }) : { allowed: false, retryAfterSeconds: 60 };
86
+ if (decision.allowed)
87
+ return null;
88
+ const seconds = Number.isSafeInteger(decision.retryAfterSeconds) && decision.retryAfterSeconds > 0
89
+ ? decision.retryAfterSeconds
90
+ : 60;
91
+ const responseHeaders = new Headers(headers);
92
+ responseHeaders.set("Retry-After", String(seconds));
93
+ return failure(responseHeaders, 429);
94
+ }
95
+ catch (error) {
96
+ await report(action === "oidc-start" ? "start" : "callback", error);
97
+ return failure(headers, 503);
98
+ }
99
+ };
100
+ const handler = async (request) => {
101
+ const current = new URL(request.url);
102
+ if (request.method !== "GET")
103
+ return new Response("Method not allowed", { status: 405, headers: { Allow: "GET", "Cache-Control": "no-store" } });
104
+ if (current.pathname === new URL(options.callbackUrl).pathname) {
105
+ const headers = new Headers({ "Cache-Control": "no-store", "Set-Cookie": clearCookie });
106
+ try {
107
+ const limited = await rateLimited("oidc-callback", request, headers);
108
+ if (limited)
109
+ return limited;
110
+ const callbackRequest = callbackRequestUrl(request);
111
+ if (!callbackRequest)
112
+ return failure(headers);
113
+ const state = callbackRequest.searchParams.get("state");
114
+ const browserState = readStateCookie(request);
115
+ if (!state || !browserState || state !== browserState)
116
+ return failure(headers);
117
+ const transaction = await options.transactions.consume({ state, nowMs: Date.now() });
118
+ if (!transaction || transaction.state !== state || transaction.expiresAtMs <= Date.now())
119
+ return failure(headers);
120
+ const client = await getConfig();
121
+ const tokens = await oidc.authorizationCodeGrant(client, callbackRequest, {
122
+ pkceCodeVerifier: transaction.codeVerifier,
123
+ expectedState: transaction.state,
124
+ expectedNonce: transaction.nonce,
125
+ idTokenExpected: true,
126
+ }, { redirect_uri: options.callbackUrl });
127
+ const claims = tokens.claims();
128
+ if (!claims?.sub || claims.iss !== issuerUrl.href)
129
+ return failure(headers);
130
+ const user = await options.resolveUser({ issuer: claims.iss, subject: claims.sub });
131
+ if (!user)
132
+ return failure(headers);
133
+ const sessionResponse = await options.auth.createSessionResponse(request, user.id);
134
+ if (sessionResponse.status !== 200) {
135
+ return failure(headers, sessionResponse.status >= 500 ? 500 : 400);
136
+ }
137
+ const responseHeaders = new Headers(sessionResponse.headers);
138
+ responseHeaders.append("Set-Cookie", clearCookie);
139
+ responseHeaders.set("Location", new URL(options.successPath, callbackUrl.origin).href);
140
+ responseHeaders.set("Cache-Control", "no-store");
141
+ return new Response(null, { status: 303, headers: responseHeaders });
142
+ }
143
+ catch (error) {
144
+ await report("callback", error);
145
+ return failure(headers);
146
+ }
147
+ }
148
+ if (current.pathname !== new URL(callbackUrl.origin + "/start").pathname) {
149
+ return new Response("Not found", { status: 404, headers: { "Cache-Control": "no-store" } });
150
+ }
151
+ try {
152
+ const limited = await rateLimited("oidc-start", request);
153
+ if (limited)
154
+ return limited;
155
+ const state = oidc.randomState();
156
+ const nonce = oidc.randomNonce();
157
+ const codeVerifier = oidc.randomPKCECodeVerifier();
158
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
159
+ await options.transactions.create({ state, nonce, codeVerifier, expiresAtMs: Date.now() + ttl * 1000 });
160
+ const client = await getConfig();
161
+ const destination = oidc.buildAuthorizationUrl(client, {
162
+ redirect_uri: options.callbackUrl,
163
+ scope: options.scope ?? "openid email",
164
+ state,
165
+ nonce,
166
+ code_challenge: codeChallenge,
167
+ code_challenge_method: "S256",
168
+ });
169
+ return new Response(null, { status: 302, headers: {
170
+ Location: destination.href,
171
+ "Set-Cookie": cookie(state, ttl),
172
+ "Cache-Control": "no-store",
173
+ "Referrer-Policy": "no-referrer",
174
+ } });
175
+ }
176
+ catch (error) {
177
+ await report("start", error);
178
+ return failure();
179
+ }
180
+ };
181
+ return Object.assign(handler, {
182
+ async cleanupExpired() {
183
+ try {
184
+ await options.transactions.deleteExpired({ nowMs: Date.now(), limit: 500 });
185
+ }
186
+ catch (error) {
187
+ await report("cleanup", error);
188
+ throw error;
189
+ }
190
+ },
191
+ });
192
+ }
@@ -0,0 +1,18 @@
1
+ export declare class RequestError extends Error {
2
+ readonly status: number;
3
+ readonly code: string;
4
+ constructor(status: number, code: string);
5
+ }
6
+ export declare function readJson(request: Request, maxBytes: number): Promise<unknown>;
7
+ export declare function parseEmailRequest(value: unknown): string;
8
+ export declare function parseActionTokenRequest(value: unknown): string;
9
+ export declare function parseEmail(value: unknown): string;
10
+ export declare function parseActionToken(value: unknown): string;
11
+ export declare function parseCredentials(value: unknown): {
12
+ email: string;
13
+ password: string;
14
+ };
15
+ export declare function parsePasswordReset(value: unknown): {
16
+ token: string;
17
+ password: string;
18
+ };
@@ -0,0 +1,93 @@
1
+ export class RequestError extends Error {
2
+ status;
3
+ code;
4
+ constructor(status, code) {
5
+ super(code);
6
+ this.status = status;
7
+ this.code = code;
8
+ }
9
+ }
10
+ export async function readJson(request, maxBytes) {
11
+ const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
12
+ if (contentType !== "application/json")
13
+ throw new RequestError(415, "unsupported_media_type");
14
+ const declaredLength = request.headers.get("content-length");
15
+ if (declaredLength !== null) {
16
+ const length = Number(declaredLength);
17
+ if (!Number.isSafeInteger(length) || length < 0)
18
+ throw new RequestError(400, "invalid_request");
19
+ if (length > maxBytes)
20
+ throw new RequestError(413, "request_too_large");
21
+ }
22
+ if (!request.body)
23
+ throw new RequestError(400, "invalid_request");
24
+ const reader = request.body.getReader();
25
+ const chunks = [];
26
+ let size = 0;
27
+ try {
28
+ while (true) {
29
+ const { done, value } = await reader.read();
30
+ if (done)
31
+ break;
32
+ size += value.byteLength;
33
+ if (size > maxBytes) {
34
+ await reader.cancel();
35
+ throw new RequestError(413, "request_too_large");
36
+ }
37
+ chunks.push(value);
38
+ }
39
+ }
40
+ finally {
41
+ reader.releaseLock();
42
+ }
43
+ const bytes = new Uint8Array(size);
44
+ let offset = 0;
45
+ for (const chunk of chunks) {
46
+ bytes.set(chunk, offset);
47
+ offset += chunk.byteLength;
48
+ }
49
+ try {
50
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
51
+ }
52
+ catch {
53
+ throw new RequestError(400, "invalid_request");
54
+ }
55
+ }
56
+ function objectFields(value) {
57
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
58
+ throw new RequestError(400, "invalid_request");
59
+ }
60
+ return value;
61
+ }
62
+ export function parseEmailRequest(value) {
63
+ return parseEmail(objectFields(value).email);
64
+ }
65
+ export function parseActionTokenRequest(value) {
66
+ return parseActionToken(objectFields(value).token);
67
+ }
68
+ export function parseEmail(value) {
69
+ if (typeof value !== "string" || value.length > 254 || !value.includes("@") || /\s/.test(value)) {
70
+ throw new RequestError(400, "invalid_request");
71
+ }
72
+ return value.trim().toLowerCase();
73
+ }
74
+ export function parseActionToken(value) {
75
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
76
+ throw new RequestError(400, "invalid_request");
77
+ }
78
+ return value;
79
+ }
80
+ export function parseCredentials(value) {
81
+ const { email, password } = objectFields(value);
82
+ if (typeof password !== "string" || password.length === 0 || password.length > 4096) {
83
+ throw new RequestError(400, "invalid_request");
84
+ }
85
+ return { email: parseEmail(email), password };
86
+ }
87
+ export function parsePasswordReset(value) {
88
+ const { token, password } = objectFields(value);
89
+ if (typeof password !== "string" || password.length === 0 || password.length > 4096) {
90
+ throw new RequestError(400, "invalid_request");
91
+ }
92
+ return { token: parseActionToken(token), password };
93
+ }