@pramen/auth 0.0.6 → 0.0.8
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 +136 -0
- package/dist/index.js +259 -6
- package/package.json +6 -3
- package/src/index.ts +314 -7
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HandlerContext, Policy } from "@pramen/server";
|
|
1
2
|
export declare const authSchema: {
|
|
2
3
|
auth_users: import("@pramen/server").EntityDef<{
|
|
3
4
|
username: {
|
|
@@ -7,10 +8,22 @@ export declare const authSchema: {
|
|
|
7
8
|
};
|
|
8
9
|
passwordHash: {
|
|
9
10
|
readonly type: "text";
|
|
11
|
+
} & {
|
|
12
|
+
readonly hidden: true;
|
|
10
13
|
};
|
|
11
14
|
roles: {
|
|
12
15
|
readonly type: "json";
|
|
13
16
|
};
|
|
17
|
+
email: {
|
|
18
|
+
readonly type: "text";
|
|
19
|
+
} & {
|
|
20
|
+
readonly unique: true;
|
|
21
|
+
};
|
|
22
|
+
active: {
|
|
23
|
+
readonly type: "boolean";
|
|
24
|
+
} & {
|
|
25
|
+
readonly default: true;
|
|
26
|
+
};
|
|
14
27
|
createdAt: {
|
|
15
28
|
readonly type: "integer";
|
|
16
29
|
};
|
|
@@ -46,3 +59,126 @@ export declare const authHandlers: {
|
|
|
46
59
|
}>;
|
|
47
60
|
me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
|
|
48
61
|
};
|
|
62
|
+
export declare const magicLinkSchema: {
|
|
63
|
+
auth_magic_links: import("@pramen/server").EntityDef<{
|
|
64
|
+
tokenHash: {
|
|
65
|
+
readonly type: "text";
|
|
66
|
+
readonly primaryKey: true;
|
|
67
|
+
readonly notNull: true;
|
|
68
|
+
};
|
|
69
|
+
email: {
|
|
70
|
+
readonly type: "text";
|
|
71
|
+
};
|
|
72
|
+
expiresAt: {
|
|
73
|
+
readonly type: "integer";
|
|
74
|
+
};
|
|
75
|
+
consumedAt: {
|
|
76
|
+
readonly type: "integer";
|
|
77
|
+
};
|
|
78
|
+
createdAt: {
|
|
79
|
+
readonly type: "integer";
|
|
80
|
+
};
|
|
81
|
+
}, Record<string, never>>;
|
|
82
|
+
};
|
|
83
|
+
export interface MagicLinkOptions {
|
|
84
|
+
/** Deliver the link to the recipient. Receives the handler ctx and the raw token —
|
|
85
|
+
* build the URL however your app routes it, e.g. `${ctx.env.APP_URL}/auth?token=${token}`.
|
|
86
|
+
* On Cloudflare the recommended transport is Cloudflare Email Sending — a
|
|
87
|
+
* `send_email` binding (no API keys), e.g.
|
|
88
|
+
* `await (ctx.env.EMAIL as SendEmail).send({ to, from: { email, name }, subject, text, html })`
|
|
89
|
+
* (see example/app.ts + oblaka.ts). Throwing rolls back the mutation, so a delivery
|
|
90
|
+
* failure leaves no orphan token and surfaces to the caller to retry. */
|
|
91
|
+
sendEmail: (ctx: HandlerContext, args: {
|
|
92
|
+
email: string;
|
|
93
|
+
token: string;
|
|
94
|
+
}) => void | Promise<void>;
|
|
95
|
+
/** How long the emailed link stays valid, in seconds. Default 900 (15 min). */
|
|
96
|
+
linkTtlSeconds?: number;
|
|
97
|
+
/** TTL of the session JWT minted on successful login, in seconds. Default 3600 (1h). */
|
|
98
|
+
sessionTtlSeconds?: number;
|
|
99
|
+
/** Roles assigned when a magic-link login first creates the user. Default `["user"]`. */
|
|
100
|
+
defaultRoles?: string[];
|
|
101
|
+
}
|
|
102
|
+
/** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
|
|
103
|
+
* result into your handler map (and `magicLinkSchema` into your schema). Both are
|
|
104
|
+
* anonymous — gate nothing; the token is the capability. */
|
|
105
|
+
export declare function createMagicLinkAuth(opts: MagicLinkOptions): {
|
|
106
|
+
requestMagicLink: import("@pramen/server").Handler<{
|
|
107
|
+
email: string;
|
|
108
|
+
}, {
|
|
109
|
+
ok: boolean;
|
|
110
|
+
}>;
|
|
111
|
+
loginWithMagicLink: import("@pramen/server").Handler<{
|
|
112
|
+
token: string;
|
|
113
|
+
}, {
|
|
114
|
+
token: string;
|
|
115
|
+
user: {
|
|
116
|
+
username: string;
|
|
117
|
+
roles: string[];
|
|
118
|
+
};
|
|
119
|
+
}>;
|
|
120
|
+
};
|
|
121
|
+
/** Admin + self-service handlers over `auth_users`. Spread into your handler map
|
|
122
|
+
* alongside `authHandlers`; gate them by spreading `authPolicies()` into your roles. */
|
|
123
|
+
export declare const userHandlers: {
|
|
124
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
125
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
126
|
+
listUsers: import("@pramen/server").Handler<{
|
|
127
|
+
limit?: number;
|
|
128
|
+
offset?: number;
|
|
129
|
+
}, (import("@pramen/server").InferRow<import("@pramen/server").EntityFields> & Partial<{
|
|
130
|
+
[x: string]: import("@pramen/server").InferRow<import("@pramen/server").EntityFields> | import("@pramen/server").InferRow<import("@pramen/server").EntityFields>[] | null;
|
|
131
|
+
}>)[]>;
|
|
132
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
133
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
134
|
+
setUserRoles: import("@pramen/server").Handler<{
|
|
135
|
+
username: string;
|
|
136
|
+
roles: string[];
|
|
137
|
+
}, Record<string, unknown>>;
|
|
138
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
139
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
140
|
+
setUserActive: import("@pramen/server").Handler<{
|
|
141
|
+
username: string;
|
|
142
|
+
active: boolean;
|
|
143
|
+
}, Record<string, unknown>>;
|
|
144
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
145
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
146
|
+
deleteUser: import("@pramen/server").Handler<{
|
|
147
|
+
username: string;
|
|
148
|
+
}, {
|
|
149
|
+
ok: boolean;
|
|
150
|
+
}>;
|
|
151
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
152
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
153
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
154
|
+
changeEmail: import("@pramen/server").Handler<{
|
|
155
|
+
email: string;
|
|
156
|
+
}, Record<string, unknown>>;
|
|
157
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
158
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
159
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
160
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
161
|
+
* password and are rejected. */
|
|
162
|
+
changePassword: import("@pramen/server").Handler<{
|
|
163
|
+
currentPassword: string;
|
|
164
|
+
newPassword: string;
|
|
165
|
+
}, {
|
|
166
|
+
ok: boolean;
|
|
167
|
+
}>;
|
|
168
|
+
};
|
|
169
|
+
/** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
|
|
170
|
+
* role and `self` into your authenticated-user role:
|
|
171
|
+
*
|
|
172
|
+
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
173
|
+
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
174
|
+
*
|
|
175
|
+
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
176
|
+
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
177
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed. */
|
|
178
|
+
export declare function authPolicies(opts?: {
|
|
179
|
+
table?: string;
|
|
180
|
+
identityPath?: string;
|
|
181
|
+
}): {
|
|
182
|
+
admin: Policy[];
|
|
183
|
+
self: Policy[];
|
|
184
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -11,13 +11,19 @@
|
|
|
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
|
-
|
|
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.
|
|
18
|
+
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
|
|
15
19
|
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
16
20
|
export const authSchema = {
|
|
17
21
|
auth_users: Entity((t) => ({
|
|
18
|
-
username: t.textId(),
|
|
19
|
-
passwordHash: t.text(),
|
|
22
|
+
username: t.textId(), // stable identity / PK (= the JWT `sub`); not the email
|
|
23
|
+
passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
|
|
20
24
|
roles: t.json(), // string[]
|
|
25
|
+
email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
|
|
26
|
+
active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
|
|
21
27
|
createdAt: t.int(),
|
|
22
28
|
})),
|
|
23
29
|
};
|
|
@@ -82,6 +88,12 @@ function secretOf(ctx) {
|
|
|
82
88
|
}
|
|
83
89
|
const DEFAULT_ROLES = ["user"];
|
|
84
90
|
const TOKEN_TTL_SECONDS = 3600;
|
|
91
|
+
/** Session-token lifetime: AUTH_SESSION_TTL_SECONDS from the env (a deployment can
|
|
92
|
+
* shorten it to tighten the deactivation/role-change window), else 1h. */
|
|
93
|
+
function sessionTtlOf(ctx) {
|
|
94
|
+
const v = Number(ctx.env.AUTH_SESSION_TTL_SECONDS);
|
|
95
|
+
return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
|
|
96
|
+
}
|
|
85
97
|
function parseCreds(raw) {
|
|
86
98
|
const o = (raw ?? {});
|
|
87
99
|
if (typeof o.username !== "string" || o.username.length === 0)
|
|
@@ -99,18 +111,259 @@ export const authHandlers = {
|
|
|
99
111
|
throw new BadRequest("username is taken");
|
|
100
112
|
const roles = DEFAULT_ROLES;
|
|
101
113
|
await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", input.username, await hashPassword(input.password), JSON.stringify(roles), Date.now());
|
|
102
|
-
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds:
|
|
114
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
103
115
|
return { token, user: { username: input.username, roles } };
|
|
104
116
|
}, { input: parseCreds }),
|
|
105
117
|
login: mutation(async (ctx, input) => {
|
|
106
|
-
const rows = await ctx.db.exec("SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
118
|
+
const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
107
119
|
const u = rows[0];
|
|
108
120
|
if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
109
121
|
throw new Unauthorized("invalid username or password");
|
|
110
122
|
}
|
|
123
|
+
// Only after the password verifies (so this can't enumerate accounts): a
|
|
124
|
+
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
125
|
+
if (!isActive(u.active))
|
|
126
|
+
throw new Unauthorized("account is deactivated");
|
|
111
127
|
const roles = JSON.parse(String(u.roles));
|
|
112
|
-
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds:
|
|
128
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
113
129
|
return { token, user: { username: String(u.username), roles } };
|
|
114
130
|
}, { input: parseCreds }),
|
|
115
131
|
me: query((ctx) => ctx.identity),
|
|
116
132
|
};
|
|
133
|
+
// --- magic link (passwordless) login ---------------------------------------
|
|
134
|
+
//
|
|
135
|
+
// A one-time, single-use, time-boxed link emailed to the user. The flow is two
|
|
136
|
+
// anonymous mutations:
|
|
137
|
+
// requestMagicLink({ email }) -> mints a token, persists its HASH + expiry, and
|
|
138
|
+
// calls your sendEmail. Always returns { ok: true }
|
|
139
|
+
// (no account enumeration — the response is the
|
|
140
|
+
// same whether or not the email has an account).
|
|
141
|
+
// loginWithMagicLink({ token }) -> validates the token (unexpired, unconsumed),
|
|
142
|
+
// consumes it, find-or-creates the auth_users row
|
|
143
|
+
// (passwordless: empty passwordHash never verifies),
|
|
144
|
+
// and returns the same { token, user } as login.
|
|
145
|
+
//
|
|
146
|
+
// The emailed user is keyed by email in the `username` column, so a magic-link user
|
|
147
|
+
// and a password user with the same handle are the same row. Tokens are stored only
|
|
148
|
+
// as a SHA-256 hash, so a DB leak never exposes a live link.
|
|
149
|
+
// Spread alongside authSchema so the link table is migrated.
|
|
150
|
+
export const magicLinkSchema = {
|
|
151
|
+
auth_magic_links: Entity((t) => ({
|
|
152
|
+
tokenHash: t.textId(), // PK = sha256(token); the raw token only ever leaves via email
|
|
153
|
+
email: t.text(),
|
|
154
|
+
expiresAt: t.int(), // epoch ms
|
|
155
|
+
consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
|
|
156
|
+
createdAt: t.int(),
|
|
157
|
+
})),
|
|
158
|
+
};
|
|
159
|
+
async function sha256Hex(s) {
|
|
160
|
+
const digest = await crypto.subtle.digest("SHA-256", enc(s));
|
|
161
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
162
|
+
}
|
|
163
|
+
/** 256 bits of entropy, url-safe — the raw link token. */
|
|
164
|
+
function mintToken() {
|
|
165
|
+
return b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
166
|
+
}
|
|
167
|
+
function parseEmail(raw) {
|
|
168
|
+
const o = (raw ?? {});
|
|
169
|
+
const email = typeof o.email === "string" ? o.email.trim().toLowerCase() : "";
|
|
170
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
|
171
|
+
throw new BadRequest("a valid email is required");
|
|
172
|
+
return { email };
|
|
173
|
+
}
|
|
174
|
+
function parseLinkToken(raw) {
|
|
175
|
+
const o = (raw ?? {});
|
|
176
|
+
if (typeof o.token !== "string" || o.token.length === 0)
|
|
177
|
+
throw new BadRequest("token is required");
|
|
178
|
+
return { token: o.token };
|
|
179
|
+
}
|
|
180
|
+
/** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
|
|
181
|
+
* result into your handler map (and `magicLinkSchema` into your schema). Both are
|
|
182
|
+
* anonymous — gate nothing; the token is the capability. */
|
|
183
|
+
export function createMagicLinkAuth(opts) {
|
|
184
|
+
const linkTtlMs = (opts.linkTtlSeconds ?? 900) * 1000;
|
|
185
|
+
const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
|
|
186
|
+
const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
|
|
187
|
+
return {
|
|
188
|
+
requestMagicLink: mutation(async (ctx, input) => {
|
|
189
|
+
const token = mintToken();
|
|
190
|
+
const tokenHash = await sha256Hex(token);
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
// Invalidate any prior pending links for this email — only the latest works.
|
|
193
|
+
await ctx.db.exec("DELETE FROM auth_magic_links WHERE email = ?", input.email);
|
|
194
|
+
await ctx.db.exec("INSERT INTO auth_magic_links (tokenHash, email, expiresAt, createdAt) VALUES (?, ?, ?, ?)", tokenHash, input.email, now + linkTtlMs, now);
|
|
195
|
+
// Inside the mutation transaction: a throw here rolls the token back.
|
|
196
|
+
await opts.sendEmail(ctx, { email: input.email, token });
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}, { input: parseEmail }),
|
|
199
|
+
loginWithMagicLink: mutation(async (ctx, input) => {
|
|
200
|
+
const tokenHash = await sha256Hex(input.token);
|
|
201
|
+
const rows = await ctx.db.exec("SELECT email, expiresAt, consumedAt FROM auth_magic_links WHERE tokenHash = ? LIMIT 1", tokenHash);
|
|
202
|
+
const link = rows[0];
|
|
203
|
+
if (!link || link.consumedAt != null || Number(link.expiresAt) < Date.now()) {
|
|
204
|
+
throw new Unauthorized("invalid or expired link");
|
|
205
|
+
}
|
|
206
|
+
// Single-use: consume before issuing the session.
|
|
207
|
+
await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
|
|
208
|
+
const email = String(link.email);
|
|
209
|
+
// Match on the EMAIL column (not username), so a magic link for an address a
|
|
210
|
+
// password user later set via changeEmail logs into THAT account — username
|
|
211
|
+
// (the JWT sub) stays the user's stable identity. New users key username = email.
|
|
212
|
+
const existing = await ctx.db.exec("SELECT username, roles, active FROM auth_users WHERE email = ? LIMIT 1", email);
|
|
213
|
+
let username;
|
|
214
|
+
let roles;
|
|
215
|
+
if (existing.length > 0) {
|
|
216
|
+
if (!isActive(existing[0].active))
|
|
217
|
+
throw new Unauthorized("account is deactivated");
|
|
218
|
+
username = String(existing[0].username);
|
|
219
|
+
roles = JSON.parse(String(existing[0].roles));
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
username = email;
|
|
223
|
+
roles = defaultRoles;
|
|
224
|
+
await ctx.db.exec(
|
|
225
|
+
// Empty passwordHash can never verify → the user stays passwordless.
|
|
226
|
+
"INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)", email, "", JSON.stringify(roles), email, Date.now());
|
|
227
|
+
}
|
|
228
|
+
const token = await signToken({ sub: username, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
|
|
229
|
+
return { token, user: { username, roles } };
|
|
230
|
+
}, { input: parseLinkToken }),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// --- user management ---------------------------------------------------------
|
|
234
|
+
//
|
|
235
|
+
// Admin + self-service operations over `auth_users`, built the pramen way: ordinary
|
|
236
|
+
// handlers over `ctx.db` whose authorization is the ACL, not imperative `if (admin)`
|
|
237
|
+
// checks. They are inert until you grant access — spread `authPolicies()` into your
|
|
238
|
+
// roles (admin manages everyone; the authenticated user manages only itself). Because
|
|
239
|
+
// the admin read policy restricts `fields`, `passwordHash` is never projected back.
|
|
240
|
+
//
|
|
241
|
+
// Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
|
|
242
|
+
// take effect on the user's NEXT login — not instantly. That lag is the cost of the
|
|
243
|
+
// stateless, verify-only core (no session store, by design). Tune the revocation window
|
|
244
|
+
// with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
|
|
245
|
+
// app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
|
|
246
|
+
// deliberately left to the app rather than building a session store into the core.
|
|
247
|
+
/** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
|
|
248
|
+
function isActive(v) {
|
|
249
|
+
return v == null || Number(v) !== 0;
|
|
250
|
+
}
|
|
251
|
+
function requireUserId(ctx) {
|
|
252
|
+
const id = ctx.identity?.userId;
|
|
253
|
+
if (typeof id !== "string" || id.length === 0)
|
|
254
|
+
throw new Unauthorized("authentication required");
|
|
255
|
+
return id;
|
|
256
|
+
}
|
|
257
|
+
const usersDb = (ctx) => ctx.db;
|
|
258
|
+
/** Admin + self-service handlers over `auth_users`. Spread into your handler map
|
|
259
|
+
* alongside `authHandlers`; gate them by spreading `authPolicies()` into your roles. */
|
|
260
|
+
export const userHandlers = {
|
|
261
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
262
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
263
|
+
listUsers: query(async (ctx, input) => {
|
|
264
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
265
|
+
const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
|
|
266
|
+
return ctx.db.find({ from: "auth_users", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
|
|
267
|
+
}),
|
|
268
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
269
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
270
|
+
setUserRoles: mutation(async (ctx, input) => {
|
|
271
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
272
|
+
throw new BadRequest("username is required");
|
|
273
|
+
if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
|
|
274
|
+
throw new BadRequest("roles must be a non-empty string[]");
|
|
275
|
+
}
|
|
276
|
+
const updated = await usersDb(ctx).update("auth_users", input.username, { roles: input.roles });
|
|
277
|
+
if (!updated)
|
|
278
|
+
throw new BadRequest("user not found"); // (or out of the caller's update scope)
|
|
279
|
+
return updated;
|
|
280
|
+
}),
|
|
281
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
282
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
283
|
+
setUserActive: mutation(async (ctx, input) => {
|
|
284
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
285
|
+
throw new BadRequest("username is required");
|
|
286
|
+
if (typeof input?.active !== "boolean")
|
|
287
|
+
throw new BadRequest("active must be a boolean");
|
|
288
|
+
if (input.active === false && input.username === ctx.identity?.userId) {
|
|
289
|
+
throw new BadRequest("cannot deactivate your own account");
|
|
290
|
+
}
|
|
291
|
+
const updated = await usersDb(ctx).update("auth_users", input.username, { active: input.active });
|
|
292
|
+
if (!updated)
|
|
293
|
+
throw new BadRequest("user not found");
|
|
294
|
+
return updated;
|
|
295
|
+
}),
|
|
296
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
297
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
298
|
+
deleteUser: mutation(async (ctx, input) => {
|
|
299
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
300
|
+
throw new BadRequest("username is required");
|
|
301
|
+
if (input.username === ctx.identity?.userId)
|
|
302
|
+
throw new BadRequest("cannot delete your own account");
|
|
303
|
+
const deleted = await usersDb(ctx).delete("auth_users", input.username);
|
|
304
|
+
if (!deleted)
|
|
305
|
+
throw new BadRequest("user not found");
|
|
306
|
+
return { ok: true };
|
|
307
|
+
}),
|
|
308
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
309
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
310
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
311
|
+
changeEmail: mutation(async (ctx, input) => {
|
|
312
|
+
const userId = requireUserId(ctx);
|
|
313
|
+
const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
|
|
314
|
+
const taken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? AND username != ? LIMIT 1", email, userId);
|
|
315
|
+
if (taken.length > 0)
|
|
316
|
+
throw new BadRequest("email already in use");
|
|
317
|
+
const updated = await usersDb(ctx).update("auth_users", userId, { email });
|
|
318
|
+
if (!updated)
|
|
319
|
+
throw new Unauthorized("authentication required");
|
|
320
|
+
return updated;
|
|
321
|
+
}),
|
|
322
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
323
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
324
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
325
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
326
|
+
* password and are rejected. */
|
|
327
|
+
changePassword: mutation(async (ctx, input) => {
|
|
328
|
+
const userId = requireUserId(ctx);
|
|
329
|
+
const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
|
|
330
|
+
const next = typeof input?.newPassword === "string" ? input.newPassword : "";
|
|
331
|
+
if (next.length < 8)
|
|
332
|
+
throw new BadRequest("newPassword must be at least 8 characters");
|
|
333
|
+
const rows = await ctx.db.exec("SELECT passwordHash FROM auth_users WHERE username = ? LIMIT 1", userId);
|
|
334
|
+
const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
|
|
335
|
+
if (stored === "" || !(await verifyPassword(current, stored))) {
|
|
336
|
+
throw new Unauthorized("current password is incorrect");
|
|
337
|
+
}
|
|
338
|
+
await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(next), userId);
|
|
339
|
+
return { ok: true };
|
|
340
|
+
}),
|
|
341
|
+
};
|
|
342
|
+
// Fields a self-service caller may see of their own row (never passwordHash/roles).
|
|
343
|
+
const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
|
|
344
|
+
// Fields an admin may see of any user (never passwordHash).
|
|
345
|
+
const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
|
|
346
|
+
/** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
|
|
347
|
+
* role and `self` into your authenticated-user role:
|
|
348
|
+
*
|
|
349
|
+
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
350
|
+
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
351
|
+
*
|
|
352
|
+
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
353
|
+
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
354
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed. */
|
|
355
|
+
export function authPolicies(opts = {}) {
|
|
356
|
+
const table = opts.table ?? "auth_users";
|
|
357
|
+
const idPath = opts.identityPath ?? "userId";
|
|
358
|
+
return {
|
|
359
|
+
admin: [
|
|
360
|
+
policy("auth:admin:read", table, "read", { fields: ADMIN_READ_FIELDS }),
|
|
361
|
+
policy("auth:admin:update", table, "update", { fields: ["roles", "email", "active"] }),
|
|
362
|
+
policy("auth:admin:delete", table, "delete", allow()),
|
|
363
|
+
],
|
|
364
|
+
self: [
|
|
365
|
+
policy("auth:self:read", table, "read", { where: { username: $identity(idPath) }, fields: SELF_READ_FIELDS }),
|
|
366
|
+
policy("auth:self:update", table, "update", { where: { username: $identity(idPath) }, fields: ["email"] }),
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
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.8"
|
|
35
38
|
}
|
|
36
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,17 +11,23 @@
|
|
|
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
|
-
import { Entity, mutation, query, BadRequest, Unauthorized } from "@pramen/server";
|
|
16
|
-
import type { HandlerContext } from "@pramen/server";
|
|
19
|
+
import { Entity, mutation, query, defaultTo, unique, hidden, policy, allow, $identity, BadRequest, Unauthorized } from "@pramen/server";
|
|
20
|
+
import type { HandlerContext, Policy } from "@pramen/server";
|
|
17
21
|
|
|
18
22
|
// --- schema fragment: spread into your defineSchema so the table is migrated ---
|
|
19
23
|
|
|
20
24
|
export const authSchema = {
|
|
21
25
|
auth_users: Entity((t) => ({
|
|
22
|
-
username: t.textId(),
|
|
23
|
-
passwordHash: t.text(),
|
|
26
|
+
username: t.textId(), // stable identity / PK (= the JWT `sub`); not the email
|
|
27
|
+
passwordHash: hidden(t.text()), // never readable via the ORM; empty for passwordless users
|
|
24
28
|
roles: t.json(), // string[]
|
|
29
|
+
email: unique(t.text()), // mutable contact email (nullable, unique); the magic-link key
|
|
30
|
+
active: defaultTo(t.bool(), true), // deactivation flag — false blocks login (additive, backfills 1)
|
|
25
31
|
createdAt: t.int(),
|
|
26
32
|
})),
|
|
27
33
|
};
|
|
@@ -105,6 +111,13 @@ function secretOf(ctx: HandlerContext): string {
|
|
|
105
111
|
const DEFAULT_ROLES = ["user"];
|
|
106
112
|
const TOKEN_TTL_SECONDS = 3600;
|
|
107
113
|
|
|
114
|
+
/** Session-token lifetime: AUTH_SESSION_TTL_SECONDS from the env (a deployment can
|
|
115
|
+
* shorten it to tighten the deactivation/role-change window), else 1h. */
|
|
116
|
+
function sessionTtlOf(ctx: HandlerContext): number {
|
|
117
|
+
const v = Number(ctx.env.AUTH_SESSION_TTL_SECONDS);
|
|
118
|
+
return Number.isFinite(v) && v > 0 ? Math.trunc(v) : TOKEN_TTL_SECONDS;
|
|
119
|
+
}
|
|
120
|
+
|
|
108
121
|
function parseCreds(raw: unknown): { username: string; password: string } {
|
|
109
122
|
const o = (raw ?? {}) as Record<string, unknown>;
|
|
110
123
|
if (typeof o.username !== "string" || o.username.length === 0) throw new Error("username is required");
|
|
@@ -127,7 +140,7 @@ export const authHandlers = {
|
|
|
127
140
|
JSON.stringify(roles),
|
|
128
141
|
Date.now(),
|
|
129
142
|
);
|
|
130
|
-
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds:
|
|
143
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
131
144
|
return { token, user: { username: input.username, roles } };
|
|
132
145
|
},
|
|
133
146
|
{ input: parseCreds },
|
|
@@ -136,15 +149,18 @@ export const authHandlers = {
|
|
|
136
149
|
login: mutation(
|
|
137
150
|
async (ctx, input: { username: string; password: string }) => {
|
|
138
151
|
const rows = await ctx.db.exec(
|
|
139
|
-
"SELECT username, passwordHash, roles FROM auth_users WHERE username = ? LIMIT 1",
|
|
152
|
+
"SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1",
|
|
140
153
|
input.username,
|
|
141
154
|
);
|
|
142
155
|
const u = rows[0];
|
|
143
156
|
if (!u || !(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
144
157
|
throw new Unauthorized("invalid username or password");
|
|
145
158
|
}
|
|
159
|
+
// Only after the password verifies (so this can't enumerate accounts): a
|
|
160
|
+
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
161
|
+
if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
|
|
146
162
|
const roles = JSON.parse(String(u.roles)) as string[];
|
|
147
|
-
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds:
|
|
163
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
148
164
|
return { token, user: { username: String(u.username), roles } };
|
|
149
165
|
},
|
|
150
166
|
{ input: parseCreds },
|
|
@@ -152,3 +168,294 @@ export const authHandlers = {
|
|
|
152
168
|
|
|
153
169
|
me: query((ctx) => ctx.identity),
|
|
154
170
|
};
|
|
171
|
+
|
|
172
|
+
// --- magic link (passwordless) login ---------------------------------------
|
|
173
|
+
//
|
|
174
|
+
// A one-time, single-use, time-boxed link emailed to the user. The flow is two
|
|
175
|
+
// anonymous mutations:
|
|
176
|
+
// requestMagicLink({ email }) -> mints a token, persists its HASH + expiry, and
|
|
177
|
+
// calls your sendEmail. Always returns { ok: true }
|
|
178
|
+
// (no account enumeration — the response is the
|
|
179
|
+
// same whether or not the email has an account).
|
|
180
|
+
// loginWithMagicLink({ token }) -> validates the token (unexpired, unconsumed),
|
|
181
|
+
// consumes it, find-or-creates the auth_users row
|
|
182
|
+
// (passwordless: empty passwordHash never verifies),
|
|
183
|
+
// and returns the same { token, user } as login.
|
|
184
|
+
//
|
|
185
|
+
// The emailed user is keyed by email in the `username` column, so a magic-link user
|
|
186
|
+
// and a password user with the same handle are the same row. Tokens are stored only
|
|
187
|
+
// as a SHA-256 hash, so a DB leak never exposes a live link.
|
|
188
|
+
|
|
189
|
+
// Spread alongside authSchema so the link table is migrated.
|
|
190
|
+
export const magicLinkSchema = {
|
|
191
|
+
auth_magic_links: Entity((t) => ({
|
|
192
|
+
tokenHash: t.textId(), // PK = sha256(token); the raw token only ever leaves via email
|
|
193
|
+
email: t.text(),
|
|
194
|
+
expiresAt: t.int(), // epoch ms
|
|
195
|
+
consumedAt: t.int(), // epoch ms; NULL until redeemed (single-use)
|
|
196
|
+
createdAt: t.int(),
|
|
197
|
+
})),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
async function sha256Hex(s: string): Promise<string> {
|
|
201
|
+
const digest = await crypto.subtle.digest("SHA-256", enc(s));
|
|
202
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 256 bits of entropy, url-safe — the raw link token. */
|
|
206
|
+
function mintToken(): string {
|
|
207
|
+
return b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseEmail(raw: unknown): { email: string } {
|
|
211
|
+
const o = (raw ?? {}) as Record<string, unknown>;
|
|
212
|
+
const email = typeof o.email === "string" ? o.email.trim().toLowerCase() : "";
|
|
213
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new BadRequest("a valid email is required");
|
|
214
|
+
return { email };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseLinkToken(raw: unknown): { token: string } {
|
|
218
|
+
const o = (raw ?? {}) as Record<string, unknown>;
|
|
219
|
+
if (typeof o.token !== "string" || o.token.length === 0) throw new BadRequest("token is required");
|
|
220
|
+
return { token: o.token };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface MagicLinkOptions {
|
|
224
|
+
/** Deliver the link to the recipient. Receives the handler ctx and the raw token —
|
|
225
|
+
* build the URL however your app routes it, e.g. `${ctx.env.APP_URL}/auth?token=${token}`.
|
|
226
|
+
* On Cloudflare the recommended transport is Cloudflare Email Sending — a
|
|
227
|
+
* `send_email` binding (no API keys), e.g.
|
|
228
|
+
* `await (ctx.env.EMAIL as SendEmail).send({ to, from: { email, name }, subject, text, html })`
|
|
229
|
+
* (see example/app.ts + oblaka.ts). Throwing rolls back the mutation, so a delivery
|
|
230
|
+
* failure leaves no orphan token and surfaces to the caller to retry. */
|
|
231
|
+
sendEmail: (ctx: HandlerContext, args: { email: string; token: string }) => void | Promise<void>;
|
|
232
|
+
/** How long the emailed link stays valid, in seconds. Default 900 (15 min). */
|
|
233
|
+
linkTtlSeconds?: number;
|
|
234
|
+
/** TTL of the session JWT minted on successful login, in seconds. Default 3600 (1h). */
|
|
235
|
+
sessionTtlSeconds?: number;
|
|
236
|
+
/** Roles assigned when a magic-link login first creates the user. Default `["user"]`. */
|
|
237
|
+
defaultRoles?: string[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Build the `requestMagicLink` / `loginWithMagicLink` handler pair. Spread the
|
|
241
|
+
* result into your handler map (and `magicLinkSchema` into your schema). Both are
|
|
242
|
+
* anonymous — gate nothing; the token is the capability. */
|
|
243
|
+
export function createMagicLinkAuth(opts: MagicLinkOptions) {
|
|
244
|
+
const linkTtlMs = (opts.linkTtlSeconds ?? 900) * 1000;
|
|
245
|
+
const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
|
|
246
|
+
const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
requestMagicLink: mutation(
|
|
250
|
+
async (ctx, input: { email: string }) => {
|
|
251
|
+
const token = mintToken();
|
|
252
|
+
const tokenHash = await sha256Hex(token);
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
// Invalidate any prior pending links for this email — only the latest works.
|
|
255
|
+
await ctx.db.exec("DELETE FROM auth_magic_links WHERE email = ?", input.email);
|
|
256
|
+
await ctx.db.exec(
|
|
257
|
+
"INSERT INTO auth_magic_links (tokenHash, email, expiresAt, createdAt) VALUES (?, ?, ?, ?)",
|
|
258
|
+
tokenHash,
|
|
259
|
+
input.email,
|
|
260
|
+
now + linkTtlMs,
|
|
261
|
+
now,
|
|
262
|
+
);
|
|
263
|
+
// Inside the mutation transaction: a throw here rolls the token back.
|
|
264
|
+
await opts.sendEmail(ctx, { email: input.email, token });
|
|
265
|
+
return { ok: true };
|
|
266
|
+
},
|
|
267
|
+
{ input: parseEmail },
|
|
268
|
+
),
|
|
269
|
+
|
|
270
|
+
loginWithMagicLink: mutation(
|
|
271
|
+
async (ctx, input: { token: string }) => {
|
|
272
|
+
const tokenHash = await sha256Hex(input.token);
|
|
273
|
+
const rows = await ctx.db.exec(
|
|
274
|
+
"SELECT email, expiresAt, consumedAt FROM auth_magic_links WHERE tokenHash = ? LIMIT 1",
|
|
275
|
+
tokenHash,
|
|
276
|
+
);
|
|
277
|
+
const link = rows[0];
|
|
278
|
+
if (!link || link.consumedAt != null || Number(link.expiresAt) < Date.now()) {
|
|
279
|
+
throw new Unauthorized("invalid or expired link");
|
|
280
|
+
}
|
|
281
|
+
// Single-use: consume before issuing the session.
|
|
282
|
+
await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
|
|
283
|
+
|
|
284
|
+
const email = String(link.email);
|
|
285
|
+
// Match on the EMAIL column (not username), so a magic link for an address a
|
|
286
|
+
// password user later set via changeEmail logs into THAT account — username
|
|
287
|
+
// (the JWT sub) stays the user's stable identity. New users key username = email.
|
|
288
|
+
const existing = await ctx.db.exec(
|
|
289
|
+
"SELECT username, roles, active FROM auth_users WHERE email = ? LIMIT 1",
|
|
290
|
+
email,
|
|
291
|
+
);
|
|
292
|
+
let username: string;
|
|
293
|
+
let roles: string[];
|
|
294
|
+
if (existing.length > 0) {
|
|
295
|
+
if (!isActive(existing[0].active)) throw new Unauthorized("account is deactivated");
|
|
296
|
+
username = String(existing[0].username);
|
|
297
|
+
roles = JSON.parse(String(existing[0].roles)) as string[];
|
|
298
|
+
} else {
|
|
299
|
+
username = email;
|
|
300
|
+
roles = defaultRoles;
|
|
301
|
+
await ctx.db.exec(
|
|
302
|
+
// Empty passwordHash can never verify → the user stays passwordless.
|
|
303
|
+
"INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
|
|
304
|
+
email,
|
|
305
|
+
"",
|
|
306
|
+
JSON.stringify(roles),
|
|
307
|
+
email,
|
|
308
|
+
Date.now(),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const token = await signToken({ sub: username, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
|
|
312
|
+
return { token, user: { username, roles } };
|
|
313
|
+
},
|
|
314
|
+
{ input: parseLinkToken },
|
|
315
|
+
),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- user management ---------------------------------------------------------
|
|
320
|
+
//
|
|
321
|
+
// Admin + self-service operations over `auth_users`, built the pramen way: ordinary
|
|
322
|
+
// handlers over `ctx.db` whose authorization is the ACL, not imperative `if (admin)`
|
|
323
|
+
// checks. They are inert until you grant access — spread `authPolicies()` into your
|
|
324
|
+
// roles (admin manages everyone; the authenticated user manages only itself). Because
|
|
325
|
+
// the admin read policy restricts `fields`, `passwordHash` is never projected back.
|
|
326
|
+
//
|
|
327
|
+
// Role/active changes are baked into the JWT at login, so setUserRoles / setUserActive
|
|
328
|
+
// take effect on the user's NEXT login — not instantly. That lag is the cost of the
|
|
329
|
+
// stateless, verify-only core (no session store, by design). Tune the revocation window
|
|
330
|
+
// with the AUTH_SESSION_TTL_SECONDS env var (default 3600); for immediate revocation an
|
|
331
|
+
// app can keep a per-user denylist in ctx.kv and check it in a route/middleware —
|
|
332
|
+
// deliberately left to the app rather than building a session store into the core.
|
|
333
|
+
|
|
334
|
+
/** SQLite has no bool: `active` is stored 0/1 (NULL on a pre-column row = active). */
|
|
335
|
+
function isActive(v: unknown): boolean {
|
|
336
|
+
return v == null || Number(v) !== 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function requireUserId(ctx: HandlerContext): string {
|
|
340
|
+
const id = ctx.identity?.userId;
|
|
341
|
+
if (typeof id !== "string" || id.length === 0) throw new Unauthorized("authentication required");
|
|
342
|
+
return id;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// `ctx.db` is schema-typed against the *app's* composed schema, which this package
|
|
346
|
+
// can't import — so address auth_users through a minimal structural view of the ACL'd
|
|
347
|
+
// Db. This is the same ctx.db at runtime: row-scope + field projection still apply.
|
|
348
|
+
interface AuthUsersDb {
|
|
349
|
+
update(table: "auth_users", id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | undefined>;
|
|
350
|
+
delete(table: "auth_users", id: string): Promise<boolean>;
|
|
351
|
+
}
|
|
352
|
+
const usersDb = (ctx: HandlerContext): AuthUsersDb => ctx.db as unknown as AuthUsersDb;
|
|
353
|
+
|
|
354
|
+
/** Admin + self-service handlers over `auth_users`. Spread into your handler map
|
|
355
|
+
* alongside `authHandlers`; gate them by spreading `authPolicies()` into your roles. */
|
|
356
|
+
export const userHandlers = {
|
|
357
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
358
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
359
|
+
listUsers: query(async (ctx, input: { limit?: number; offset?: number }) => {
|
|
360
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
361
|
+
const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
|
|
362
|
+
return ctx.db.find({ from: "auth_users", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
|
|
363
|
+
}),
|
|
364
|
+
|
|
365
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
366
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
367
|
+
setUserRoles: mutation(async (ctx, input: { username: string; roles: string[] }) => {
|
|
368
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
369
|
+
if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
|
|
370
|
+
throw new BadRequest("roles must be a non-empty string[]");
|
|
371
|
+
}
|
|
372
|
+
const updated = await usersDb(ctx).update("auth_users", input.username, { roles: input.roles });
|
|
373
|
+
if (!updated) throw new BadRequest("user not found"); // (or out of the caller's update scope)
|
|
374
|
+
return updated;
|
|
375
|
+
}),
|
|
376
|
+
|
|
377
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
378
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
379
|
+
setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
|
|
380
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
381
|
+
if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
|
|
382
|
+
if (input.active === false && input.username === ctx.identity?.userId) {
|
|
383
|
+
throw new BadRequest("cannot deactivate your own account");
|
|
384
|
+
}
|
|
385
|
+
const updated = await usersDb(ctx).update("auth_users", input.username, { active: input.active });
|
|
386
|
+
if (!updated) throw new BadRequest("user not found");
|
|
387
|
+
return updated;
|
|
388
|
+
}),
|
|
389
|
+
|
|
390
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
391
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
392
|
+
deleteUser: mutation(async (ctx, input: { username: string }) => {
|
|
393
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
394
|
+
if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
|
|
395
|
+
const deleted = await usersDb(ctx).delete("auth_users", input.username);
|
|
396
|
+
if (!deleted) throw new BadRequest("user not found");
|
|
397
|
+
return { ok: true };
|
|
398
|
+
}),
|
|
399
|
+
|
|
400
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
401
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
402
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
403
|
+
changeEmail: mutation(async (ctx, input: { email: string }) => {
|
|
404
|
+
const userId = requireUserId(ctx);
|
|
405
|
+
const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
|
|
406
|
+
const taken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? AND username != ? LIMIT 1", email, userId);
|
|
407
|
+
if (taken.length > 0) throw new BadRequest("email already in use");
|
|
408
|
+
const updated = await usersDb(ctx).update("auth_users", userId, { email });
|
|
409
|
+
if (!updated) throw new Unauthorized("authentication required");
|
|
410
|
+
return updated;
|
|
411
|
+
}),
|
|
412
|
+
|
|
413
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
414
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
415
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
416
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
417
|
+
* password and are rejected. */
|
|
418
|
+
changePassword: mutation(async (ctx, input: { currentPassword: string; newPassword: string }) => {
|
|
419
|
+
const userId = requireUserId(ctx);
|
|
420
|
+
const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
|
|
421
|
+
const next = typeof input?.newPassword === "string" ? input.newPassword : "";
|
|
422
|
+
if (next.length < 8) throw new BadRequest("newPassword must be at least 8 characters");
|
|
423
|
+
const rows = await ctx.db.exec("SELECT passwordHash FROM auth_users WHERE username = ? LIMIT 1", userId);
|
|
424
|
+
const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
|
|
425
|
+
if (stored === "" || !(await verifyPassword(current, stored))) {
|
|
426
|
+
throw new Unauthorized("current password is incorrect");
|
|
427
|
+
}
|
|
428
|
+
await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(next), userId);
|
|
429
|
+
return { ok: true };
|
|
430
|
+
}),
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Fields a self-service caller may see of their own row (never passwordHash/roles).
|
|
434
|
+
const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
|
|
435
|
+
// Fields an admin may see of any user (never passwordHash).
|
|
436
|
+
const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
|
|
437
|
+
|
|
438
|
+
/** ACL policy fragments that turn on `userHandlers`. Spread `admin` into your admin
|
|
439
|
+
* role and `self` into your authenticated-user role:
|
|
440
|
+
*
|
|
441
|
+
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
442
|
+
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
443
|
+
*
|
|
444
|
+
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
445
|
+
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
446
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed. */
|
|
447
|
+
export function authPolicies(opts: { table?: string; identityPath?: string } = {}): { admin: Policy[]; self: Policy[] } {
|
|
448
|
+
const table = opts.table ?? "auth_users";
|
|
449
|
+
const idPath = opts.identityPath ?? "userId";
|
|
450
|
+
return {
|
|
451
|
+
admin: [
|
|
452
|
+
policy("auth:admin:read", table, "read", { fields: ADMIN_READ_FIELDS }),
|
|
453
|
+
policy("auth:admin:update", table, "update", { fields: ["roles", "email", "active"] }),
|
|
454
|
+
policy("auth:admin:delete", table, "delete", allow()),
|
|
455
|
+
],
|
|
456
|
+
self: [
|
|
457
|
+
policy("auth:self:read", table, "read", { where: { username: $identity(idPath) }, fields: SELF_READ_FIELDS }),
|
|
458
|
+
policy("auth:self:update", table, "update", { where: { username: $identity(idPath) }, fields: ["email"] }),
|
|
459
|
+
],
|
|
460
|
+
};
|
|
461
|
+
}
|