@pramen/auth 0.0.5 → 0.0.7

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/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { HandlerContext } from "@pramen/server";
1
2
  export declare const authSchema: {
2
3
  auth_users: import("@pramen/server").EntityDef<{
3
4
  username: {
@@ -46,3 +47,62 @@ export declare const authHandlers: {
46
47
  }>;
47
48
  me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
48
49
  };
50
+ export declare const magicLinkSchema: {
51
+ auth_magic_links: import("@pramen/server").EntityDef<{
52
+ tokenHash: {
53
+ readonly type: "text";
54
+ readonly primaryKey: true;
55
+ readonly notNull: true;
56
+ };
57
+ email: {
58
+ readonly type: "text";
59
+ };
60
+ expiresAt: {
61
+ readonly type: "integer";
62
+ };
63
+ consumedAt: {
64
+ readonly type: "integer";
65
+ };
66
+ createdAt: {
67
+ readonly type: "integer";
68
+ };
69
+ }, Record<string, never>>;
70
+ };
71
+ export interface MagicLinkOptions {
72
+ /** Deliver the link to the recipient. Receives the handler ctx and the raw token —
73
+ * build the URL however your app routes it, e.g. `${ctx.env.APP_URL}/auth?token=${token}`.
74
+ * On Cloudflare the recommended transport is Cloudflare Email Sending — a
75
+ * `send_email` binding (no API keys), e.g.
76
+ * `await (ctx.env.EMAIL as SendEmail).send({ to, from: { email, name }, subject, text, html })`
77
+ * (see example/app.ts + oblaka.ts). Throwing rolls back the mutation, so a delivery
78
+ * failure leaves no orphan token and surfaces to the caller to retry. */
79
+ sendEmail: (ctx: HandlerContext, args: {
80
+ email: string;
81
+ token: string;
82
+ }) => void | Promise<void>;
83
+ /** How long the emailed link stays valid, in seconds. Default 900 (15 min). */
84
+ linkTtlSeconds?: number;
85
+ /** TTL of the session JWT minted on successful login, in seconds. Default 3600 (1h). */
86
+ sessionTtlSeconds?: number;
87
+ /** Roles assigned when a magic-link login first creates the user. Default `["user"]`. */
88
+ defaultRoles?: string[];
89
+ }
90
+ /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
91
+ * result into your handler map (and `magicLinkSchema` into your schema). Both are
92
+ * anonymous — gate nothing; the token is the capability. */
93
+ export declare function createMagicLinkAuth(opts: MagicLinkOptions): {
94
+ requestMagicLink: import("@pramen/server").Handler<{
95
+ email: string;
96
+ }, {
97
+ ok: boolean;
98
+ }>;
99
+ loginWithMagicLink: import("@pramen/server").Handler<{
100
+ token: string;
101
+ }, {
102
+ token: string;
103
+ user: {
104
+ username: string;
105
+ roles: string[];
106
+ };
107
+ }>;
108
+ };
package/dist/index.js CHANGED
@@ -11,6 +11,10 @@
11
11
  // signup/login store users in the `auth_users` table and return a bearer token
12
12
  // (sub = username, roles). Passwords are PBKDF2-hashed (WebCrypto, no deps).
13
13
  // Requires AUTH_SECRET in the environment (ctx.env). JWKS setups don't use this.
14
+ //
15
+ // Passwordless magic-link login is also available via createMagicLinkAuth (spread
16
+ // magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
17
+ // the token lifecycle. See createMagicLinkAuth below.
14
18
  import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
15
19
  // --- schema fragment: spread into your defineSchema so the table is migrated ---
16
20
  export const authSchema = {
@@ -114,3 +118,95 @@ export const authHandlers = {
114
118
  }, { input: parseCreds }),
115
119
  me: query((ctx) => ctx.identity),
116
120
  };
121
+ // --- magic link (passwordless) login ---------------------------------------
122
+ //
123
+ // A one-time, single-use, time-boxed link emailed to the user. The flow is two
124
+ // anonymous mutations:
125
+ // requestMagicLink({ email }) -> mints a token, persists its HASH + expiry, and
126
+ // calls your sendEmail. Always returns { ok: true }
127
+ // (no account enumeration — the response is the
128
+ // same whether or not the email has an account).
129
+ // loginWithMagicLink({ token }) -> validates the token (unexpired, unconsumed),
130
+ // consumes it, find-or-creates the auth_users row
131
+ // (passwordless: empty passwordHash never verifies),
132
+ // and returns the same { token, user } as login.
133
+ //
134
+ // The emailed user is keyed by email in the `username` column, so a magic-link user
135
+ // and a password user with the same handle are the same row. Tokens are stored only
136
+ // as a SHA-256 hash, so a DB leak never exposes a live link.
137
+ // Spread alongside authSchema so the link table is migrated.
138
+ export const magicLinkSchema = {
139
+ auth_magic_links: Entity((t) => ({
140
+ tokenHash: t.textId(), // PK = sha256(token); the raw token only ever leaves via email
141
+ email: t.text(),
142
+ expiresAt: t.int(), // epoch ms
143
+ consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
144
+ createdAt: t.int(),
145
+ })),
146
+ };
147
+ async function sha256Hex(s) {
148
+ const digest = await crypto.subtle.digest("SHA-256", enc(s));
149
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
150
+ }
151
+ /** 256 bits of entropy, url-safe — the raw link token. */
152
+ function mintToken() {
153
+ return b64url(crypto.getRandomValues(new Uint8Array(32)));
154
+ }
155
+ function parseEmail(raw) {
156
+ const o = (raw ?? {});
157
+ const email = typeof o.email === "string" ? o.email.trim().toLowerCase() : "";
158
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
159
+ throw new BadRequest("a valid email is required");
160
+ return { email };
161
+ }
162
+ function parseLinkToken(raw) {
163
+ const o = (raw ?? {});
164
+ if (typeof o.token !== "string" || o.token.length === 0)
165
+ throw new BadRequest("token is required");
166
+ return { token: o.token };
167
+ }
168
+ /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
169
+ * result into your handler map (and `magicLinkSchema` into your schema). Both are
170
+ * anonymous — gate nothing; the token is the capability. */
171
+ export function createMagicLinkAuth(opts) {
172
+ const linkTtlMs = (opts.linkTtlSeconds ?? 900) * 1000;
173
+ const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
174
+ const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
175
+ return {
176
+ requestMagicLink: mutation(async (ctx, input) => {
177
+ const token = mintToken();
178
+ const tokenHash = await sha256Hex(token);
179
+ const now = Date.now();
180
+ // Invalidate any prior pending links for this email — only the latest works.
181
+ await ctx.db.exec("DELETE FROM auth_magic_links WHERE email = ?", input.email);
182
+ await ctx.db.exec("INSERT INTO auth_magic_links (tokenHash, email, expiresAt, createdAt) VALUES (?, ?, ?, ?)", tokenHash, input.email, now + linkTtlMs, now);
183
+ // Inside the mutation transaction: a throw here rolls the token back.
184
+ await opts.sendEmail(ctx, { email: input.email, token });
185
+ return { ok: true };
186
+ }, { input: parseEmail }),
187
+ loginWithMagicLink: mutation(async (ctx, input) => {
188
+ const tokenHash = await sha256Hex(input.token);
189
+ const rows = await ctx.db.exec("SELECT email, expiresAt, consumedAt FROM auth_magic_links WHERE tokenHash = ? LIMIT 1", tokenHash);
190
+ const link = rows[0];
191
+ if (!link || link.consumedAt != null || Number(link.expiresAt) < Date.now()) {
192
+ throw new Unauthorized("invalid or expired link");
193
+ }
194
+ // Single-use: consume before issuing the session.
195
+ await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
196
+ const email = String(link.email);
197
+ const existing = await ctx.db.exec("SELECT roles FROM auth_users WHERE username = ? LIMIT 1", email);
198
+ let roles;
199
+ if (existing.length > 0) {
200
+ roles = JSON.parse(String(existing[0].roles));
201
+ }
202
+ else {
203
+ roles = defaultRoles;
204
+ await ctx.db.exec(
205
+ // Empty passwordHash can never verify → the user stays passwordless.
206
+ "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", email, "", JSON.stringify(roles), Date.now());
207
+ }
208
+ const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
209
+ return { token, user: { username: email, roles } };
210
+ }, { input: parseLinkToken }),
211
+ };
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,10 @@
23
23
  },
24
24
  "main": "./dist/index.js",
25
25
  "types": "./dist/index.d.ts",
26
- "files": ["dist", "src"],
26
+ "files": [
27
+ "dist",
28
+ "src"
29
+ ],
27
30
  "scripts": {
28
31
  "build": "rm -rf dist && tsc -p tsconfig.build.json"
29
32
  },
@@ -31,6 +34,6 @@
31
34
  "access": "public"
32
35
  },
33
36
  "dependencies": {
34
- "@pramen/server": "workspace:*"
37
+ "@pramen/server": "0.0.7"
35
38
  }
36
39
  }
package/src/index.ts CHANGED
@@ -11,6 +11,10 @@
11
11
  // signup/login store users in the `auth_users` table and return a bearer token
12
12
  // (sub = username, roles). Passwords are PBKDF2-hashed (WebCrypto, no deps).
13
13
  // Requires AUTH_SECRET in the environment (ctx.env). JWKS setups don't use this.
14
+ //
15
+ // Passwordless magic-link login is also available via createMagicLinkAuth (spread
16
+ // magicLinkSchema too). It is transport-agnostic: you supply sendEmail; pramen owns
17
+ // the token lifecycle. See createMagicLinkAuth below.
14
18
 
15
19
  import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
16
20
  import type { HandlerContext } from "@pramen/server";
@@ -152,3 +156,139 @@ export const authHandlers = {
152
156
 
153
157
  me: query((ctx) => ctx.identity),
154
158
  };
159
+
160
+ // --- magic link (passwordless) login ---------------------------------------
161
+ //
162
+ // A one-time, single-use, time-boxed link emailed to the user. The flow is two
163
+ // anonymous mutations:
164
+ // requestMagicLink({ email }) -> mints a token, persists its HASH + expiry, and
165
+ // calls your sendEmail. Always returns { ok: true }
166
+ // (no account enumeration — the response is the
167
+ // same whether or not the email has an account).
168
+ // loginWithMagicLink({ token }) -> validates the token (unexpired, unconsumed),
169
+ // consumes it, find-or-creates the auth_users row
170
+ // (passwordless: empty passwordHash never verifies),
171
+ // and returns the same { token, user } as login.
172
+ //
173
+ // The emailed user is keyed by email in the `username` column, so a magic-link user
174
+ // and a password user with the same handle are the same row. Tokens are stored only
175
+ // as a SHA-256 hash, so a DB leak never exposes a live link.
176
+
177
+ // Spread alongside authSchema so the link table is migrated.
178
+ export const magicLinkSchema = {
179
+ auth_magic_links: Entity((t) => ({
180
+ tokenHash: t.textId(), // PK = sha256(token); the raw token only ever leaves via email
181
+ email: t.text(),
182
+ expiresAt: t.int(), // epoch ms
183
+ consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
184
+ createdAt: t.int(),
185
+ })),
186
+ };
187
+
188
+ async function sha256Hex(s: string): Promise<string> {
189
+ const digest = await crypto.subtle.digest("SHA-256", enc(s));
190
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
191
+ }
192
+
193
+ /** 256 bits of entropy, url-safe — the raw link token. */
194
+ function mintToken(): string {
195
+ return b64url(crypto.getRandomValues(new Uint8Array(32)));
196
+ }
197
+
198
+ function parseEmail(raw: unknown): { email: string } {
199
+ const o = (raw ?? {}) as Record<string, unknown>;
200
+ const email = typeof o.email === "string" ? o.email.trim().toLowerCase() : "";
201
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new BadRequest("a valid email is required");
202
+ return { email };
203
+ }
204
+
205
+ function parseLinkToken(raw: unknown): { token: string } {
206
+ const o = (raw ?? {}) as Record<string, unknown>;
207
+ if (typeof o.token !== "string" || o.token.length === 0) throw new BadRequest("token is required");
208
+ return { token: o.token };
209
+ }
210
+
211
+ export interface MagicLinkOptions {
212
+ /** Deliver the link to the recipient. Receives the handler ctx and the raw token —
213
+ * build the URL however your app routes it, e.g. `${ctx.env.APP_URL}/auth?token=${token}`.
214
+ * On Cloudflare the recommended transport is Cloudflare Email Sending — a
215
+ * `send_email` binding (no API keys), e.g.
216
+ * `await (ctx.env.EMAIL as SendEmail).send({ to, from: { email, name }, subject, text, html })`
217
+ * (see example/app.ts + oblaka.ts). Throwing rolls back the mutation, so a delivery
218
+ * failure leaves no orphan token and surfaces to the caller to retry. */
219
+ sendEmail: (ctx: HandlerContext, args: { email: string; token: string }) => void | Promise<void>;
220
+ /** How long the emailed link stays valid, in seconds. Default 900 (15 min). */
221
+ linkTtlSeconds?: number;
222
+ /** TTL of the session JWT minted on successful login, in seconds. Default 3600 (1h). */
223
+ sessionTtlSeconds?: number;
224
+ /** Roles assigned when a magic-link login first creates the user. Default `["user"]`. */
225
+ defaultRoles?: string[];
226
+ }
227
+
228
+ /** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
229
+ * result into your handler map (and `magicLinkSchema` into your schema). Both are
230
+ * anonymous — gate nothing; the token is the capability. */
231
+ export function createMagicLinkAuth(opts: MagicLinkOptions) {
232
+ const linkTtlMs = (opts.linkTtlSeconds ?? 900) * 1000;
233
+ const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
234
+ const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
235
+
236
+ return {
237
+ requestMagicLink: mutation(
238
+ async (ctx, input: { email: string }) => {
239
+ const token = mintToken();
240
+ const tokenHash = await sha256Hex(token);
241
+ const now = Date.now();
242
+ // Invalidate any prior pending links for this email — only the latest works.
243
+ await ctx.db.exec("DELETE FROM auth_magic_links WHERE email = ?", input.email);
244
+ await ctx.db.exec(
245
+ "INSERT INTO auth_magic_links (tokenHash, email, expiresAt, createdAt) VALUES (?, ?, ?, ?)",
246
+ tokenHash,
247
+ input.email,
248
+ now + linkTtlMs,
249
+ now,
250
+ );
251
+ // Inside the mutation transaction: a throw here rolls the token back.
252
+ await opts.sendEmail(ctx, { email: input.email, token });
253
+ return { ok: true };
254
+ },
255
+ { input: parseEmail },
256
+ ),
257
+
258
+ loginWithMagicLink: mutation(
259
+ async (ctx, input: { token: string }) => {
260
+ const tokenHash = await sha256Hex(input.token);
261
+ const rows = await ctx.db.exec(
262
+ "SELECT email, expiresAt, consumedAt FROM auth_magic_links WHERE tokenHash = ? LIMIT 1",
263
+ tokenHash,
264
+ );
265
+ const link = rows[0];
266
+ if (!link || link.consumedAt != null || Number(link.expiresAt) < Date.now()) {
267
+ throw new Unauthorized("invalid or expired link");
268
+ }
269
+ // Single-use: consume before issuing the session.
270
+ await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
271
+
272
+ const email = String(link.email);
273
+ const existing = await ctx.db.exec("SELECT roles FROM auth_users WHERE username = ? LIMIT 1", email);
274
+ let roles: string[];
275
+ if (existing.length > 0) {
276
+ roles = JSON.parse(String(existing[0].roles)) as string[];
277
+ } else {
278
+ roles = defaultRoles;
279
+ await ctx.db.exec(
280
+ // Empty passwordHash can never verify → the user stays passwordless.
281
+ "INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
282
+ email,
283
+ "",
284
+ JSON.stringify(roles),
285
+ Date.now(),
286
+ );
287
+ }
288
+ const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
289
+ return { token, user: { username: email, roles } };
290
+ },
291
+ { input: parseLinkToken },
292
+ ),
293
+ };
294
+ }