@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 +60 -0
- package/dist/index.js +96 -0
- package/package.json +6 -3
- package/src/index.ts +140 -0
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.
|
|
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": [
|
|
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": "
|
|
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
|
+
}
|