@pramen/auth 0.0.38 → 0.0.40
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 -2
- package/dist/index.js +129 -61
- package/package.json +2 -2
- package/src/index.ts +187 -77
package/dist/index.d.ts
CHANGED
|
@@ -33,12 +33,70 @@ export declare const authSchema: {
|
|
|
33
33
|
}, Record<string, never>>;
|
|
34
34
|
};
|
|
35
35
|
export declare function hashPassword(password: string): Promise<string>;
|
|
36
|
+
/** Verify `password` against a foreign hash `payload` (the stored value with its
|
|
37
|
+
* `<scheme>$` prefix already stripped). May be sync or async; throwing counts as a
|
|
38
|
+
* failed verification, never a 500. */
|
|
39
|
+
export type PasswordVerifier = (password: string, payload: string) => boolean | Promise<boolean>;
|
|
40
|
+
/** Register a verifier for an imported hash scheme. Call once at module scope, before
|
|
41
|
+
* any login can run. `pbkdf2` is built in and cannot be overridden. */
|
|
42
|
+
export declare function registerPasswordVerifier(scheme: string, verify: PasswordVerifier): void;
|
|
43
|
+
/** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
|
|
44
|
+
* after a successful verify. Also true for an unparseable value, which never verifies. */
|
|
45
|
+
export declare function isForeignHash(stored: string): boolean;
|
|
36
46
|
export declare function verifyPassword(password: string, stored: string): Promise<boolean>;
|
|
37
47
|
export declare function signToken(claims: Record<string, unknown>, secret: string, opts?: {
|
|
38
48
|
ttlSeconds?: number;
|
|
39
49
|
}): Promise<string>;
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
export interface AuthHandlerOptions {
|
|
51
|
+
/** Which column `login` resolves the submitted identifier against.
|
|
52
|
+
*
|
|
53
|
+
* - `"username"` (default) — the PK only, the historical behaviour.
|
|
54
|
+
* - `"email"` — the email column only. For apps where the username is an opaque id
|
|
55
|
+
* (a migrated tenant identity, say) and members know only their email address.
|
|
56
|
+
* - `"either"` — username first (it is the PK, so it cannot be ambiguous), then email.
|
|
57
|
+
* Use when two populations coexist: migrated members keyed by an opaque id, and
|
|
58
|
+
* newer accounts that signed up with their email as the username.
|
|
59
|
+
*
|
|
60
|
+
* Email lookup tries an exact match, then falls back to a case-insensitive one — but
|
|
61
|
+
* ONLY when that matches exactly one row, so a pair of addresses differing just by
|
|
62
|
+
* case can never resolve to an arbitrary account. */
|
|
63
|
+
loginBy?: "username" | "email" | "either";
|
|
64
|
+
}
|
|
65
|
+
/** Build signup / login / me / refreshSession. Roles are assigned server-side (default
|
|
66
|
+
* `["user"]`) — the client never picks its own roles. Spread into your handler map. */
|
|
67
|
+
export declare function createAuthHandlers(opts?: AuthHandlerOptions): {
|
|
68
|
+
signup: import("@pramen/server").Handler<{
|
|
69
|
+
username: string;
|
|
70
|
+
password: string;
|
|
71
|
+
email?: string;
|
|
72
|
+
}, {
|
|
73
|
+
token: string;
|
|
74
|
+
user: {
|
|
75
|
+
username: string;
|
|
76
|
+
roles: string[];
|
|
77
|
+
email: string | null;
|
|
78
|
+
};
|
|
79
|
+
}>;
|
|
80
|
+
login: import("@pramen/server").Handler<{
|
|
81
|
+
username: string;
|
|
82
|
+
password: string;
|
|
83
|
+
}, {
|
|
84
|
+
token: string;
|
|
85
|
+
user: {
|
|
86
|
+
username: string;
|
|
87
|
+
roles: string[];
|
|
88
|
+
};
|
|
89
|
+
}>;
|
|
90
|
+
me: import("@pramen/server").Handler<unknown, import("@pramen/server").Identity | null>;
|
|
91
|
+
refreshSession: import("@pramen/server").Handler<unknown, {
|
|
92
|
+
token: string;
|
|
93
|
+
user: {
|
|
94
|
+
username: string;
|
|
95
|
+
roles: string[];
|
|
96
|
+
};
|
|
97
|
+
}>;
|
|
98
|
+
};
|
|
99
|
+
/** Default handlers: login resolves by username only (the historical behaviour). */
|
|
42
100
|
export declare const authHandlers: {
|
|
43
101
|
signup: import("@pramen/server").Handler<{
|
|
44
102
|
username: string;
|
package/dist/index.js
CHANGED
|
@@ -84,7 +84,38 @@ function parseStoredHash(stored) {
|
|
|
84
84
|
const hash = algSeg === "sha512" ? "SHA-512" : "SHA-256";
|
|
85
85
|
return { iterations, hash, saltB64, hashB64 };
|
|
86
86
|
}
|
|
87
|
+
const foreignVerifiers = new Map();
|
|
88
|
+
/** Register a verifier for an imported hash scheme. Call once at module scope, before
|
|
89
|
+
* any login can run. `pbkdf2` is built in and cannot be overridden. */
|
|
90
|
+
export function registerPasswordVerifier(scheme, verify) {
|
|
91
|
+
if (!scheme || scheme.includes("$"))
|
|
92
|
+
throw new Error(`invalid hash scheme '${scheme}' (must be non-empty and contain no '$')`);
|
|
93
|
+
if (scheme === "pbkdf2")
|
|
94
|
+
throw new Error("'pbkdf2' is built in and cannot be overridden");
|
|
95
|
+
foreignVerifiers.set(scheme, verify);
|
|
96
|
+
}
|
|
97
|
+
/** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
|
|
98
|
+
* after a successful verify. Also true for an unparseable value, which never verifies. */
|
|
99
|
+
export function isForeignHash(stored) {
|
|
100
|
+
return stored.split("$")[0] !== "pbkdf2";
|
|
101
|
+
}
|
|
87
102
|
export async function verifyPassword(password, stored) {
|
|
103
|
+
const scheme = stored.split("$")[0];
|
|
104
|
+
if (scheme !== "pbkdf2") {
|
|
105
|
+
const verify = foreignVerifiers.get(scheme);
|
|
106
|
+
// Unknown scheme (including the empty passwordHash of a passwordless user) never
|
|
107
|
+
// verifies — same as before this feature existed.
|
|
108
|
+
if (!verify)
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
return await verify(password, stored.slice(scheme.length + 1));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A broken/garbage payload must fail closed, not surface as a 500 that
|
|
115
|
+
// distinguishes it from a wrong password.
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
88
119
|
const parsed = parseStoredHash(stored);
|
|
89
120
|
if (!parsed)
|
|
90
121
|
return false;
|
|
@@ -173,68 +204,105 @@ function buildRefreshSession(ttlOf, table = "auth_users") {
|
|
|
173
204
|
return { token, user: { username: String(u.username), roles } };
|
|
174
205
|
}, { auth: "authenticated" });
|
|
175
206
|
}
|
|
176
|
-
/** signup / login / me / refreshSession. Roles are assigned server-side (default
|
|
177
|
-
* — the client never picks its own roles. Spread into your handler map. */
|
|
178
|
-
export
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// Equalize timing with the available path (which hashes below) so the taken vs.
|
|
191
|
-
// available decision isn't a fast timing oracle on top of the response-body one.
|
|
192
|
-
await hashPassword(input.password);
|
|
193
|
-
throw new BadRequest("username is taken");
|
|
194
|
-
}
|
|
195
|
-
// A supplied email must be free (the column is unique). Same clean-400 shape as
|
|
196
|
-
// changeEmail rather than surfacing the DB constraint as a 500.
|
|
197
|
-
if (input.email) {
|
|
198
|
-
const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
|
|
199
|
-
if (emailTaken.length > 0)
|
|
200
|
-
throw new BadRequest("email already in use");
|
|
201
|
-
}
|
|
202
|
-
const roles = DEFAULT_ROLES;
|
|
203
|
-
const passwordHash = await hashPassword(input.password);
|
|
204
|
-
// Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
|
|
205
|
-
// createEmailVerification (requestEmailVerification runs right after signup — the
|
|
206
|
-
// client already holds the returned session token).
|
|
207
|
-
await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)", input.username, passwordHash, JSON.stringify(roles), input.email ?? null, Date.now());
|
|
208
|
-
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
209
|
-
return { token, user: { username: input.username, roles, email: input.email ?? null } };
|
|
210
|
-
}, { input: parseCreds }),
|
|
211
|
-
login: mutation(async (ctx, input) => {
|
|
212
|
-
const rows = await ctx.db.exec("SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
213
|
-
const u = rows[0];
|
|
214
|
-
if (!u) {
|
|
215
|
-
// No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
|
|
216
|
-
// not-found path costs the same as a wrong-password path — no timing oracle that
|
|
217
|
-
// distinguishes "unknown username" from "bad password".
|
|
218
|
-
await verifyPassword(input.password, await dummyPasswordHash());
|
|
219
|
-
throw new Unauthorized("invalid username or password");
|
|
207
|
+
/** Build signup / login / me / refreshSession. Roles are assigned server-side (default
|
|
208
|
+
* `["user"]`) — the client never picks its own roles. Spread into your handler map. */
|
|
209
|
+
export function createAuthHandlers(opts = {}) {
|
|
210
|
+
const loginBy = opts.loginBy ?? "username";
|
|
211
|
+
/** Resolve the submitted identifier to a row, per `loginBy`. Returns undefined when
|
|
212
|
+
* nothing matches; the caller still runs a dummy verify so the timing is flat. */
|
|
213
|
+
async function findLoginRow(ctx, identifier) {
|
|
214
|
+
const cols = "SELECT username, passwordHash, roles, active FROM auth_users";
|
|
215
|
+
if (loginBy !== "email") {
|
|
216
|
+
const byName = await ctx.db.exec(`${cols} WHERE username = ? LIMIT 1`, identifier);
|
|
217
|
+
if (byName[0])
|
|
218
|
+
return byName[0];
|
|
219
|
+
if (loginBy === "username")
|
|
220
|
+
return undefined;
|
|
220
221
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
222
|
+
const exact = await ctx.db.exec(`${cols} WHERE email = ? LIMIT 1`, identifier);
|
|
223
|
+
if (exact[0])
|
|
224
|
+
return exact[0];
|
|
225
|
+
// Case-insensitive fallback, accepted only when unambiguous: `unique(email)` is
|
|
226
|
+
// case-SENSITIVE, so two rows may differ only by case and neither may be picked
|
|
227
|
+
// arbitrarily.
|
|
228
|
+
const loose = await ctx.db.exec(`${cols} WHERE lower(email) = lower(?) LIMIT 2`, identifier);
|
|
229
|
+
return loose.length === 1 ? loose[0] : undefined;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
// NOTE on username enumeration: signup returns a distinct "username is taken" error,
|
|
233
|
+
// which is an enumeration oracle. This is INHERENT to systems where the username is a
|
|
234
|
+
// user-chosen, publicly-visible identifier — the caller learns "taken" the moment the
|
|
235
|
+
// name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
|
|
236
|
+
// timing side channel: both the taken and the available paths run the same expensive
|
|
237
|
+
// PBKDF2 hash before responding, so response time doesn't leak which path was taken.
|
|
238
|
+
// The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
|
|
239
|
+
// which keys on the email and always returns the same `{ ok: true }`.
|
|
240
|
+
signup: mutation(async (ctx, input) => {
|
|
241
|
+
const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
242
|
+
if (existing.length > 0) {
|
|
243
|
+
// Equalize timing with the available path (which hashes below) so the taken vs.
|
|
244
|
+
// available decision isn't a fast timing oracle on top of the response-body one.
|
|
245
|
+
await hashPassword(input.password);
|
|
246
|
+
throw new BadRequest("username is taken");
|
|
247
|
+
}
|
|
248
|
+
// A supplied email must be free (the column is unique). Same clean-400 shape as
|
|
249
|
+
// changeEmail rather than surfacing the DB constraint as a 500.
|
|
250
|
+
if (input.email) {
|
|
251
|
+
const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
|
|
252
|
+
if (emailTaken.length > 0)
|
|
253
|
+
throw new BadRequest("email already in use");
|
|
254
|
+
}
|
|
255
|
+
const roles = DEFAULT_ROLES;
|
|
256
|
+
const passwordHash = await hashPassword(input.password);
|
|
257
|
+
// Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
|
|
258
|
+
// createEmailVerification (requestEmailVerification runs right after signup — the
|
|
259
|
+
// client already holds the returned session token).
|
|
260
|
+
await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)", input.username, passwordHash, JSON.stringify(roles), input.email ?? null, Date.now());
|
|
261
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
262
|
+
return { token, user: { username: input.username, roles, email: input.email ?? null } };
|
|
263
|
+
}, { input: parseCreds }),
|
|
264
|
+
login: mutation(async (ctx, input) => {
|
|
265
|
+
const u = await findLoginRow(ctx, input.username);
|
|
266
|
+
if (!u) {
|
|
267
|
+
// No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
|
|
268
|
+
// not-found path costs the same as a wrong-password path — no timing oracle that
|
|
269
|
+
// distinguishes "unknown username" from "bad password".
|
|
270
|
+
await verifyPassword(input.password, await dummyPasswordHash());
|
|
271
|
+
throw new Unauthorized("invalid username or password");
|
|
272
|
+
}
|
|
273
|
+
if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
274
|
+
throw new Unauthorized("invalid username or password");
|
|
275
|
+
}
|
|
276
|
+
// Only after the password verifies (so this can't enumerate accounts): a
|
|
277
|
+
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
278
|
+
if (!isActive(u.active))
|
|
279
|
+
throw new Unauthorized("account is deactivated");
|
|
280
|
+
// Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
|
|
281
|
+
// moment the plaintext is in hand for a user whose hash predates pramen, so rehash
|
|
282
|
+
// to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
|
|
283
|
+
// deactivated account is never rewritten. Best-effort: a failed upgrade must not
|
|
284
|
+
// fail an otherwise valid login — the row simply upgrades on a later attempt.
|
|
285
|
+
if (isForeignHash(String(u.passwordHash))) {
|
|
286
|
+
try {
|
|
287
|
+
await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(input.password), String(u.username));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
/* keep the legacy hash; the next login retries */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const roles = JSON.parse(String(u.roles));
|
|
294
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
295
|
+
return { token, user: { username: String(u.username), roles } };
|
|
296
|
+
}, { input: parseCreds }),
|
|
297
|
+
me: query((ctx) => ctx.identity),
|
|
298
|
+
// Re-read roles/active for the caller and reissue a token at the env-configured session
|
|
299
|
+
// TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
|
|
300
|
+
// the user out, and picks up role grants without re-login. See buildRefreshSession.
|
|
301
|
+
refreshSession: buildRefreshSession(sessionTtlOf),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** Default handlers: login resolves by username only (the historical behaviour). */
|
|
305
|
+
export const authHandlers = createAuthHandlers();
|
|
238
306
|
// --- magic link (passwordless) login ---------------------------------------
|
|
239
307
|
//
|
|
240
308
|
// A one-time, single-use, time-boxed link emailed to the user. The flow is two
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.40",
|
|
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": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pramen/server": "0.0.
|
|
37
|
+
"@pramen/server": "0.0.40"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -94,7 +94,64 @@ function parseStoredHash(stored: string): { iterations: number; hash: string; sa
|
|
|
94
94
|
return { iterations, hash, saltB64, hashB64 };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// --- foreign hash schemes (opt-in, for migrating in from another system) -----
|
|
98
|
+
//
|
|
99
|
+
// Importing users from an existing app means importing hashes that are NOT PBKDF2 —
|
|
100
|
+
// bcrypt from Contember/Rails, `pbkdf2_sha256$` from Django, and so on. Those cannot be
|
|
101
|
+
// converted (that needs the plaintext), so the only alternative would be forcing every
|
|
102
|
+
// user to reset their password.
|
|
103
|
+
//
|
|
104
|
+
// Instead: store the foreign hash under its own scheme prefix (`bcrypt$<payload>`),
|
|
105
|
+
// register a verifier for that scheme, and let login UPGRADE it. On the one successful
|
|
106
|
+
// login where the plaintext is briefly in hand, the row is rehashed to PBKDF2. The
|
|
107
|
+
// scheme deletes itself as users return; nothing has to be migrated ahead of time.
|
|
108
|
+
//
|
|
109
|
+
// pramen deliberately does NOT bundle an implementation. bcrypt needs a pure-JS library
|
|
110
|
+
// (WebCrypto has none) which is real bundle weight, and it is useless to the apps that
|
|
111
|
+
// never import anything — so the app supplies it:
|
|
112
|
+
//
|
|
113
|
+
// import bcrypt from "bcryptjs";
|
|
114
|
+
// registerPasswordVerifier("bcrypt", (password, payload) => bcrypt.compare(password, payload));
|
|
115
|
+
//
|
|
116
|
+
// then import rows with `passwordHash = "bcrypt$" + row.password_hash` (a bcrypt hash is
|
|
117
|
+
// itself `$2b$10$...`, so the stored value reads `bcrypt$$2b$10$...`).
|
|
118
|
+
|
|
119
|
+
/** Verify `password` against a foreign hash `payload` (the stored value with its
|
|
120
|
+
* `<scheme>$` prefix already stripped). May be sync or async; throwing counts as a
|
|
121
|
+
* failed verification, never a 500. */
|
|
122
|
+
export type PasswordVerifier = (password: string, payload: string) => boolean | Promise<boolean>;
|
|
123
|
+
|
|
124
|
+
const foreignVerifiers = new Map<string, PasswordVerifier>();
|
|
125
|
+
|
|
126
|
+
/** Register a verifier for an imported hash scheme. Call once at module scope, before
|
|
127
|
+
* any login can run. `pbkdf2` is built in and cannot be overridden. */
|
|
128
|
+
export function registerPasswordVerifier(scheme: string, verify: PasswordVerifier): void {
|
|
129
|
+
if (!scheme || scheme.includes("$")) throw new Error(`invalid hash scheme '${scheme}' (must be non-empty and contain no '$')`);
|
|
130
|
+
if (scheme === "pbkdf2") throw new Error("'pbkdf2' is built in and cannot be overridden");
|
|
131
|
+
foreignVerifiers.set(scheme, verify);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True if `stored` is a foreign (non-PBKDF2) hash, i.e. one that login should upgrade
|
|
135
|
+
* after a successful verify. Also true for an unparseable value, which never verifies. */
|
|
136
|
+
export function isForeignHash(stored: string): boolean {
|
|
137
|
+
return stored.split("$")[0] !== "pbkdf2";
|
|
138
|
+
}
|
|
139
|
+
|
|
97
140
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
141
|
+
const scheme = stored.split("$")[0];
|
|
142
|
+
if (scheme !== "pbkdf2") {
|
|
143
|
+
const verify = foreignVerifiers.get(scheme);
|
|
144
|
+
// Unknown scheme (including the empty passwordHash of a passwordless user) never
|
|
145
|
+
// verifies — same as before this feature existed.
|
|
146
|
+
if (!verify) return false;
|
|
147
|
+
try {
|
|
148
|
+
return await verify(password, stored.slice(scheme.length + 1));
|
|
149
|
+
} catch {
|
|
150
|
+
// A broken/garbage payload must fail closed, not surface as a 500 that
|
|
151
|
+
// distinguishes it from a wrong password.
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
98
155
|
const parsed = parseStoredHash(stored);
|
|
99
156
|
if (!parsed) return false;
|
|
100
157
|
const key = await crypto.subtle.importKey("raw", enc(password), "PBKDF2", false, ["deriveBits"]);
|
|
@@ -199,85 +256,138 @@ function buildRefreshSession(ttlOf: (ctx: HandlerContext) => number, table = "au
|
|
|
199
256
|
);
|
|
200
257
|
}
|
|
201
258
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// Equalize timing with the available path (which hashes below) so the taken vs.
|
|
218
|
-
// available decision isn't a fast timing oracle on top of the response-body one.
|
|
219
|
-
await hashPassword(input.password);
|
|
220
|
-
throw new BadRequest("username is taken");
|
|
221
|
-
}
|
|
222
|
-
// A supplied email must be free (the column is unique). Same clean-400 shape as
|
|
223
|
-
// changeEmail rather than surfacing the DB constraint as a 500.
|
|
224
|
-
if (input.email) {
|
|
225
|
-
const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
|
|
226
|
-
if (emailTaken.length > 0) throw new BadRequest("email already in use");
|
|
227
|
-
}
|
|
228
|
-
const roles = DEFAULT_ROLES;
|
|
229
|
-
const passwordHash = await hashPassword(input.password);
|
|
230
|
-
// Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
|
|
231
|
-
// createEmailVerification (requestEmailVerification runs right after signup — the
|
|
232
|
-
// client already holds the returned session token).
|
|
233
|
-
await ctx.db.exec(
|
|
234
|
-
"INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
|
|
235
|
-
input.username,
|
|
236
|
-
passwordHash,
|
|
237
|
-
JSON.stringify(roles),
|
|
238
|
-
input.email ?? null,
|
|
239
|
-
Date.now(),
|
|
240
|
-
);
|
|
241
|
-
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
242
|
-
return { token, user: { username: input.username, roles, email: input.email ?? null } };
|
|
243
|
-
},
|
|
244
|
-
{ input: parseCreds },
|
|
245
|
-
),
|
|
246
|
-
|
|
247
|
-
login: mutation(
|
|
248
|
-
async (ctx, input: { username: string; password: string }) => {
|
|
249
|
-
const rows = await ctx.db.exec(
|
|
250
|
-
"SELECT username, passwordHash, roles, active FROM auth_users WHERE username = ? LIMIT 1",
|
|
251
|
-
input.username,
|
|
252
|
-
);
|
|
253
|
-
const u = rows[0];
|
|
254
|
-
if (!u) {
|
|
255
|
-
// No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
|
|
256
|
-
// not-found path costs the same as a wrong-password path — no timing oracle that
|
|
257
|
-
// distinguishes "unknown username" from "bad password".
|
|
258
|
-
await verifyPassword(input.password, await dummyPasswordHash());
|
|
259
|
-
throw new Unauthorized("invalid username or password");
|
|
260
|
-
}
|
|
261
|
-
if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
262
|
-
throw new Unauthorized("invalid username or password");
|
|
263
|
-
}
|
|
264
|
-
// Only after the password verifies (so this can't enumerate accounts): a
|
|
265
|
-
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
266
|
-
if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
|
|
267
|
-
const roles = JSON.parse(String(u.roles)) as string[];
|
|
268
|
-
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
269
|
-
return { token, user: { username: String(u.username), roles } };
|
|
270
|
-
},
|
|
271
|
-
{ input: parseCreds },
|
|
272
|
-
),
|
|
259
|
+
export interface AuthHandlerOptions {
|
|
260
|
+
/** Which column `login` resolves the submitted identifier against.
|
|
261
|
+
*
|
|
262
|
+
* - `"username"` (default) — the PK only, the historical behaviour.
|
|
263
|
+
* - `"email"` — the email column only. For apps where the username is an opaque id
|
|
264
|
+
* (a migrated tenant identity, say) and members know only their email address.
|
|
265
|
+
* - `"either"` — username first (it is the PK, so it cannot be ambiguous), then email.
|
|
266
|
+
* Use when two populations coexist: migrated members keyed by an opaque id, and
|
|
267
|
+
* newer accounts that signed up with their email as the username.
|
|
268
|
+
*
|
|
269
|
+
* Email lookup tries an exact match, then falls back to a case-insensitive one — but
|
|
270
|
+
* ONLY when that matches exactly one row, so a pair of addresses differing just by
|
|
271
|
+
* case can never resolve to an arbitrary account. */
|
|
272
|
+
loginBy?: "username" | "email" | "either";
|
|
273
|
+
}
|
|
273
274
|
|
|
274
|
-
|
|
275
|
+
/** Build signup / login / me / refreshSession. Roles are assigned server-side (default
|
|
276
|
+
* `["user"]`) — the client never picks its own roles. Spread into your handler map. */
|
|
277
|
+
export function createAuthHandlers(opts: AuthHandlerOptions = {}) {
|
|
278
|
+
const loginBy = opts.loginBy ?? "username";
|
|
279
|
+
|
|
280
|
+
/** Resolve the submitted identifier to a row, per `loginBy`. Returns undefined when
|
|
281
|
+
* nothing matches; the caller still runs a dummy verify so the timing is flat. */
|
|
282
|
+
async function findLoginRow(ctx: HandlerContext, identifier: string): Promise<Record<string, unknown> | undefined> {
|
|
283
|
+
const cols = "SELECT username, passwordHash, roles, active FROM auth_users";
|
|
284
|
+
if (loginBy !== "email") {
|
|
285
|
+
const byName = await ctx.db.exec(`${cols} WHERE username = ? LIMIT 1`, identifier);
|
|
286
|
+
if (byName[0]) return byName[0];
|
|
287
|
+
if (loginBy === "username") return undefined;
|
|
288
|
+
}
|
|
289
|
+
const exact = await ctx.db.exec(`${cols} WHERE email = ? LIMIT 1`, identifier);
|
|
290
|
+
if (exact[0]) return exact[0];
|
|
291
|
+
// Case-insensitive fallback, accepted only when unambiguous: `unique(email)` is
|
|
292
|
+
// case-SENSITIVE, so two rows may differ only by case and neither may be picked
|
|
293
|
+
// arbitrarily.
|
|
294
|
+
const loose = await ctx.db.exec(`${cols} WHERE lower(email) = lower(?) LIMIT 2`, identifier);
|
|
295
|
+
return loose.length === 1 ? loose[0] : undefined;
|
|
296
|
+
}
|
|
275
297
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
298
|
+
return {
|
|
299
|
+
// NOTE on username enumeration: signup returns a distinct "username is taken" error,
|
|
300
|
+
// which is an enumeration oracle. This is INHERENT to systems where the username is a
|
|
301
|
+
// user-chosen, publicly-visible identifier — the caller learns "taken" the moment the
|
|
302
|
+
// name shows up anywhere, so hiding it at signup buys little. What we CAN close is the
|
|
303
|
+
// timing side channel: both the taken and the available paths run the same expensive
|
|
304
|
+
// PBKDF2 hash before responding, so response time doesn't leak which path was taken.
|
|
305
|
+
// The enumeration-SAFE flow is the passwordless / magic-link path (createMagicLinkAuth),
|
|
306
|
+
// which keys on the email and always returns the same `{ ok: true }`.
|
|
307
|
+
signup: mutation(
|
|
308
|
+
async (ctx, input: { username: string; password: string; email?: string }) => {
|
|
309
|
+
const existing = await ctx.db.exec("SELECT 1 FROM auth_users WHERE username = ? LIMIT 1", input.username);
|
|
310
|
+
if (existing.length > 0) {
|
|
311
|
+
// Equalize timing with the available path (which hashes below) so the taken vs.
|
|
312
|
+
// available decision isn't a fast timing oracle on top of the response-body one.
|
|
313
|
+
await hashPassword(input.password);
|
|
314
|
+
throw new BadRequest("username is taken");
|
|
315
|
+
}
|
|
316
|
+
// A supplied email must be free (the column is unique). Same clean-400 shape as
|
|
317
|
+
// changeEmail rather than surfacing the DB constraint as a 500.
|
|
318
|
+
if (input.email) {
|
|
319
|
+
const emailTaken = await ctx.db.exec("SELECT 1 FROM auth_users WHERE email = ? LIMIT 1", input.email);
|
|
320
|
+
if (emailTaken.length > 0) throw new BadRequest("email already in use");
|
|
321
|
+
}
|
|
322
|
+
const roles = DEFAULT_ROLES;
|
|
323
|
+
const passwordHash = await hashPassword(input.password);
|
|
324
|
+
// Signup stores the email UNVERIFIED (emailVerified NULL). The app confirms it via
|
|
325
|
+
// createEmailVerification (requestEmailVerification runs right after signup — the
|
|
326
|
+
// client already holds the returned session token).
|
|
327
|
+
await ctx.db.exec(
|
|
328
|
+
"INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
|
|
329
|
+
input.username,
|
|
330
|
+
passwordHash,
|
|
331
|
+
JSON.stringify(roles),
|
|
332
|
+
input.email ?? null,
|
|
333
|
+
Date.now(),
|
|
334
|
+
);
|
|
335
|
+
const token = await signToken({ sub: input.username, roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
336
|
+
return { token, user: { username: input.username, roles, email: input.email ?? null } };
|
|
337
|
+
},
|
|
338
|
+
{ input: parseCreds },
|
|
339
|
+
),
|
|
340
|
+
|
|
341
|
+
login: mutation(
|
|
342
|
+
async (ctx, input: { username: string; password: string }) => {
|
|
343
|
+
const u = await findLoginRow(ctx, input.username);
|
|
344
|
+
if (!u) {
|
|
345
|
+
// No such user: still run a full PBKDF2 verify against a fixed dummy hash so the
|
|
346
|
+
// not-found path costs the same as a wrong-password path — no timing oracle that
|
|
347
|
+
// distinguishes "unknown username" from "bad password".
|
|
348
|
+
await verifyPassword(input.password, await dummyPasswordHash());
|
|
349
|
+
throw new Unauthorized("invalid username or password");
|
|
350
|
+
}
|
|
351
|
+
if (!(await verifyPassword(input.password, String(u.passwordHash)))) {
|
|
352
|
+
throw new Unauthorized("invalid username or password");
|
|
353
|
+
}
|
|
354
|
+
// Only after the password verifies (so this can't enumerate accounts): a
|
|
355
|
+
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
356
|
+
if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
|
|
357
|
+
// Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
|
|
358
|
+
// moment the plaintext is in hand for a user whose hash predates pramen, so rehash
|
|
359
|
+
// to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
|
|
360
|
+
// deactivated account is never rewritten. Best-effort: a failed upgrade must not
|
|
361
|
+
// fail an otherwise valid login — the row simply upgrades on a later attempt.
|
|
362
|
+
if (isForeignHash(String(u.passwordHash))) {
|
|
363
|
+
try {
|
|
364
|
+
await ctx.db.exec(
|
|
365
|
+
"UPDATE auth_users SET passwordHash = ? WHERE username = ?",
|
|
366
|
+
await hashPassword(input.password),
|
|
367
|
+
String(u.username),
|
|
368
|
+
);
|
|
369
|
+
} catch {
|
|
370
|
+
/* keep the legacy hash; the next login retries */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const roles = JSON.parse(String(u.roles)) as string[];
|
|
374
|
+
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
375
|
+
return { token, user: { username: String(u.username), roles } };
|
|
376
|
+
},
|
|
377
|
+
{ input: parseCreds },
|
|
378
|
+
),
|
|
379
|
+
|
|
380
|
+
me: query((ctx) => ctx.identity),
|
|
381
|
+
|
|
382
|
+
// Re-read roles/active for the caller and reissue a token at the env-configured session
|
|
383
|
+
// TTL (AUTH_SESSION_TTL_SECONDS). Lets a short TTL bound revocation lag without logging
|
|
384
|
+
// the user out, and picks up role grants without re-login. See buildRefreshSession.
|
|
385
|
+
refreshSession: buildRefreshSession(sessionTtlOf),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Default handlers: login resolves by username only (the historical behaviour). */
|
|
390
|
+
export const authHandlers = createAuthHandlers();
|
|
281
391
|
|
|
282
392
|
// --- magic link (passwordless) login ---------------------------------------
|
|
283
393
|
//
|