@pramen/auth 0.0.38 → 0.0.39
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 +10 -0
- package/dist/index.js +44 -0
- package/package.json +2 -2
- package/src/index.ts +73 -0
package/dist/index.d.ts
CHANGED
|
@@ -33,6 +33,16 @@ 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;
|
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;
|
|
@@ -225,6 +256,19 @@ export const authHandlers = {
|
|
|
225
256
|
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
226
257
|
if (!isActive(u.active))
|
|
227
258
|
throw new Unauthorized("account is deactivated");
|
|
259
|
+
// Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
|
|
260
|
+
// moment the plaintext is in hand for a user whose hash predates pramen, so rehash
|
|
261
|
+
// to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
|
|
262
|
+
// deactivated account is never rewritten. Best-effort: a failed upgrade must not
|
|
263
|
+
// fail an otherwise valid login — the row simply upgrades on a later attempt.
|
|
264
|
+
if (isForeignHash(String(u.passwordHash))) {
|
|
265
|
+
try {
|
|
266
|
+
await ctx.db.exec("UPDATE auth_users SET passwordHash = ? WHERE username = ?", await hashPassword(input.password), String(u.username));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* keep the legacy hash; the next login retries */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
228
272
|
const roles = JSON.parse(String(u.roles));
|
|
229
273
|
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
230
274
|
return { token, user: { username: String(u.username), roles } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.39",
|
|
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.39"
|
|
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"]);
|
|
@@ -264,6 +321,22 @@ export const authHandlers = {
|
|
|
264
321
|
// Only after the password verifies (so this can't enumerate accounts): a
|
|
265
322
|
// deactivated user gets no new token. Existing tokens expire within the TTL.
|
|
266
323
|
if (!isActive(u.active)) throw new Unauthorized("account is deactivated");
|
|
324
|
+
// Upgrade an imported foreign hash (see registerPasswordVerifier). This is the one
|
|
325
|
+
// moment the plaintext is in hand for a user whose hash predates pramen, so rehash
|
|
326
|
+
// to PBKDF2 and drop the old scheme. Deliberately AFTER the active check, so a
|
|
327
|
+
// deactivated account is never rewritten. Best-effort: a failed upgrade must not
|
|
328
|
+
// fail an otherwise valid login — the row simply upgrades on a later attempt.
|
|
329
|
+
if (isForeignHash(String(u.passwordHash))) {
|
|
330
|
+
try {
|
|
331
|
+
await ctx.db.exec(
|
|
332
|
+
"UPDATE auth_users SET passwordHash = ? WHERE username = ?",
|
|
333
|
+
await hashPassword(input.password),
|
|
334
|
+
String(u.username),
|
|
335
|
+
);
|
|
336
|
+
} catch {
|
|
337
|
+
/* keep the legacy hash; the next login retries */
|
|
338
|
+
}
|
|
339
|
+
}
|
|
267
340
|
const roles = JSON.parse(String(u.roles)) as string[];
|
|
268
341
|
const token = await signToken({ sub: String(u.username), roles }, secretOf(ctx), { ttlSeconds: sessionTtlOf(ctx) });
|
|
269
342
|
return { token, user: { username: String(u.username), roles } };
|