@open-mercato/shared 0.6.8-develop.6944.1.eef6a0ee1d → 0.6.8-develop.6948.1.8369fc4c97
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/lib/auth/jwt.js +104 -8
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/auth/__tests__/jwt.test.ts +193 -0
- package/src/lib/auth/jwt.ts +157 -15
package/dist/lib/auth/jwt.js
CHANGED
|
@@ -1,18 +1,97 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { createLogger } from "../logger/index.js";
|
|
3
|
+
import { parseNumberWithDefault } from "../number.js";
|
|
4
|
+
const logger = createLogger("auth").child({ component: "jwt" });
|
|
2
5
|
function base64url(input) {
|
|
3
6
|
return (typeof input === "string" ? Buffer.from(input) : input).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
4
7
|
}
|
|
5
8
|
const DEFAULT_ISSUER = "open-mercato";
|
|
6
9
|
const DEFAULT_STAFF_AUDIENCE = "staff";
|
|
7
10
|
const AUDIENCE_SECRET_LABEL = "open-mercato:jwt:v1";
|
|
8
|
-
|
|
11
|
+
const LEGACY_GRACE_DEFAULT_MINUTES = 480;
|
|
12
|
+
const LEGACY_TOKEN_CLOCK_SKEW_SECONDS = 60;
|
|
13
|
+
function getLegacyGraceMinutes() {
|
|
9
14
|
const raw = process.env.JWT_LEGACY_GRACE_MINUTES;
|
|
10
|
-
|
|
11
|
-
return
|
|
15
|
+
const normalized = typeof raw === "string" ? raw.trim().toLowerCase() : raw;
|
|
16
|
+
if (normalized === "false" || normalized === "off") return 0;
|
|
17
|
+
return parseNumberWithDefault(normalized, LEGACY_GRACE_DEFAULT_MINUTES, { min: 0, integer: true });
|
|
18
|
+
}
|
|
19
|
+
function getLegacyCutoverEpochSeconds() {
|
|
20
|
+
const raw = process.env.JWT_LEGACY_CUTOVER_AT;
|
|
21
|
+
if (!raw || !raw.trim()) return null;
|
|
22
|
+
const parsed = Date.parse(raw.trim());
|
|
23
|
+
if (Number.isNaN(parsed)) {
|
|
24
|
+
warnOnce(
|
|
25
|
+
"jwt-legacy-cutover-unparseable",
|
|
26
|
+
"JWT_LEGACY_CUTOVER_AT is not a valid ISO-8601 instant \u2014 legacy JWT fallback remains disabled."
|
|
27
|
+
);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return Math.floor(parsed / 1e3);
|
|
31
|
+
}
|
|
32
|
+
const MIN_SECRET_LENGTH = 32;
|
|
33
|
+
const PLACEHOLDER_SECRETS = /* @__PURE__ */ new Set([
|
|
34
|
+
"jwt",
|
|
35
|
+
"jwt-secret",
|
|
36
|
+
"jwtsecret",
|
|
37
|
+
"secret",
|
|
38
|
+
"password",
|
|
39
|
+
"changeme",
|
|
40
|
+
"change-me",
|
|
41
|
+
"change-me-dev-secret",
|
|
42
|
+
"change-me-dev-auth-secret",
|
|
43
|
+
"your-strong-jwt-secret",
|
|
44
|
+
"your-secure-jwt-secret-change-me",
|
|
45
|
+
"dev",
|
|
46
|
+
"development",
|
|
47
|
+
"test"
|
|
48
|
+
]);
|
|
49
|
+
const warnedKeys = /* @__PURE__ */ new Set();
|
|
50
|
+
function warnOnce(key, message) {
|
|
51
|
+
if (warnedKeys.has(key)) return;
|
|
52
|
+
warnedKeys.add(key);
|
|
53
|
+
logger.warn(message);
|
|
54
|
+
}
|
|
55
|
+
function isProduction() {
|
|
56
|
+
return process.env.NODE_ENV === "production";
|
|
57
|
+
}
|
|
58
|
+
function inspectSecret(secret) {
|
|
59
|
+
const value = typeof secret === "string" ? secret.trim() : "";
|
|
60
|
+
if (!value) return "missing";
|
|
61
|
+
if (PLACEHOLDER_SECRETS.has(value.toLowerCase())) return "placeholder";
|
|
62
|
+
if (value.length < MIN_SECRET_LENGTH) return "too_short";
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function describeViolation(name, violation) {
|
|
66
|
+
switch (violation) {
|
|
67
|
+
case "missing":
|
|
68
|
+
return `${name} is not set. Generate one with \`openssl rand -hex 32\`.`;
|
|
69
|
+
case "placeholder":
|
|
70
|
+
return `${name} is set to a placeholder value published in this repository's examples, so anyone can forge tokens for this deployment. Generate a real one with \`openssl rand -hex 32\`.`;
|
|
71
|
+
case "too_short":
|
|
72
|
+
return `${name} is shorter than ${MIN_SECRET_LENGTH} characters. Generate a stronger one with \`openssl rand -hex 32\`.`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function enforceSecretPolicy(name, secret) {
|
|
76
|
+
const violation = inspectSecret(secret);
|
|
77
|
+
if (!violation) return;
|
|
78
|
+
const message = describeViolation(name, violation);
|
|
79
|
+
if (isProduction()) {
|
|
80
|
+
throw new Error(`[auth.jwt] Refusing to run in production with an unsafe signing secret: ${message}`);
|
|
81
|
+
}
|
|
82
|
+
warnOnce(`secret-policy:${name}:${violation}`, `${message} This is tolerated outside production only.`);
|
|
83
|
+
}
|
|
84
|
+
function assertJwtSecretPolicy() {
|
|
85
|
+
enforceSecretPolicy("JWT_SECRET", process.env.JWT_SECRET);
|
|
86
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
87
|
+
if (!/^JWT_[A-Z0-9]+(?:_[A-Z0-9]+)*_SECRET$/.test(key)) continue;
|
|
88
|
+
enforceSecretPolicy(key, value);
|
|
89
|
+
}
|
|
12
90
|
}
|
|
13
91
|
function readBaseSecret(explicit) {
|
|
14
92
|
const secret = explicit ?? process.env.JWT_SECRET;
|
|
15
93
|
if (!secret) throw new Error("JWT_SECRET is not set");
|
|
94
|
+
if (explicit === void 0) enforceSecretPolicy("JWT_SECRET", secret);
|
|
16
95
|
return secret;
|
|
17
96
|
}
|
|
18
97
|
function normalizeAudience(audience) {
|
|
@@ -33,7 +112,10 @@ function deriveJwtAudienceSecret(audience, baseSecret) {
|
|
|
33
112
|
if (!normalized) throw new Error("Audience is required to derive a JWT secret");
|
|
34
113
|
const overrideName = `JWT_${normalized.toUpperCase()}_SECRET`;
|
|
35
114
|
const override = process.env[overrideName];
|
|
36
|
-
if (override && override.trim().length > 0)
|
|
115
|
+
if (override && override.trim().length > 0) {
|
|
116
|
+
enforceSecretPolicy(overrideName, override);
|
|
117
|
+
return override;
|
|
118
|
+
}
|
|
37
119
|
const base = readBaseSecret(baseSecret);
|
|
38
120
|
return deriveAudienceSecretFromBase(normalized, base);
|
|
39
121
|
}
|
|
@@ -130,15 +212,28 @@ function verifyWithOptions(token, options) {
|
|
|
130
212
|
}
|
|
131
213
|
return payload;
|
|
132
214
|
}
|
|
215
|
+
function isWithinLegacyWindow(payload, graceMinutes) {
|
|
216
|
+
const cutoverAt = getLegacyCutoverEpochSeconds();
|
|
217
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
218
|
+
if (cutoverAt === null || now >= cutoverAt) return false;
|
|
219
|
+
const issuedAt = payload.iat;
|
|
220
|
+
if (typeof issuedAt !== "number" || !Number.isFinite(issuedAt)) return false;
|
|
221
|
+
if (issuedAt > now + LEGACY_TOKEN_CLOCK_SKEW_SECONDS) return false;
|
|
222
|
+
return now - issuedAt <= graceMinutes * 60;
|
|
223
|
+
}
|
|
133
224
|
function verifyJwt(token, secretOrOptions) {
|
|
134
225
|
const options = toVerifyOptions(secretOrOptions);
|
|
135
226
|
const result = verifyWithOptions(token, options);
|
|
136
|
-
if (result)
|
|
137
|
-
|
|
227
|
+
if (result) {
|
|
228
|
+
if (result._legacyToken !== void 0) delete result._legacyToken;
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
if (secretOrOptions === void 0) {
|
|
232
|
+
const graceMinutes = getLegacyGraceMinutes();
|
|
138
233
|
const rawSecret = process.env.JWT_SECRET;
|
|
139
|
-
if (rawSecret) {
|
|
234
|
+
if (graceMinutes > 0 && rawSecret) {
|
|
140
235
|
const legacyResult = verifyWithOptions(token, { secret: rawSecret });
|
|
141
|
-
if (legacyResult) {
|
|
236
|
+
if (legacyResult && isWithinLegacyWindow(legacyResult, graceMinutes)) {
|
|
142
237
|
legacyResult._legacyToken = true;
|
|
143
238
|
return legacyResult;
|
|
144
239
|
}
|
|
@@ -153,6 +248,7 @@ function verifyAudienceJwt(audience, token) {
|
|
|
153
248
|
return verifyJwt(token, { audience });
|
|
154
249
|
}
|
|
155
250
|
export {
|
|
251
|
+
assertJwtSecretPolicy,
|
|
156
252
|
deriveJwtAudienceSecret,
|
|
157
253
|
signAudienceJwt,
|
|
158
254
|
signJwt,
|
package/dist/lib/auth/jwt.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/auth/jwt.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto'\n\nfunction base64url(input: Buffer | string) {\n return (typeof input === 'string' ? Buffer.from(input) : input)\n .toString('base64')\n .replace(/=/g, '')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n}\n\nexport type JwtPayload = Record<string, any>\n\nexport type JwtAudience = 'staff' | 'customer' | (string & {})\n\nexport type SignJwtOptions = {\n secret?: string\n expiresInSec?: number\n audience?: string\n issuer?: string\n}\n\nexport type VerifyJwtOptions = {\n secret?: string\n audience?: string\n issuer?: string\n}\n\nconst DEFAULT_ISSUER = 'open-mercato'\nconst DEFAULT_STAFF_AUDIENCE: JwtAudience = 'staff'\nconst AUDIENCE_SECRET_LABEL = 'open-mercato:jwt:v1'\n\n/**\n * When set to a positive number (minutes), `verifyJwt` will attempt a legacy fallback using the\n * raw `JWT_SECRET` when the audience-derived verification fails. This supports rolling deployments\n * and lets existing sessions expire gracefully instead of force-logging-out every user on deploy.\n *\n * Set via `JWT_LEGACY_GRACE_MINUTES` env var. Defaults to 480 (8 hours \u2014 one full token TTL).\n * Set to 0 to disable the fallback (hard cutover).\n */\nfunction getLegacyGraceEnabled(): boolean {\n const raw = process.env.JWT_LEGACY_GRACE_MINUTES\n if (raw === '0' || raw === 'false' || raw === 'off') return false\n return true\n}\n\nfunction readBaseSecret(explicit?: string): string {\n const secret = explicit ?? process.env.JWT_SECRET\n if (!secret) throw new Error('JWT_SECRET is not set')\n return secret\n}\n\nfunction normalizeAudience(audience: string): string {\n return audience.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')\n}\n\nconst derivedSecretCache = new Map<string, string>()\n\nfunction deriveAudienceSecretFromBase(normalized: string, base: string): string {\n const cacheKey = `${normalized}::${base}`\n const cached = derivedSecretCache.get(cacheKey)\n if (cached !== undefined) return cached\n const label = `${AUDIENCE_SECRET_LABEL}:${normalized}`\n const derived = crypto.createHmac('sha256', base).update(label).digest('hex')\n derivedSecretCache.set(cacheKey, derived)\n return derived\n}\n\n/**\n * Derive a per-audience signing key from the base `JWT_SECRET`.\n *\n * - If `JWT_${AUDIENCE}_SECRET` env var is set, it is used verbatim (allows operators to rotate a\n * single audience independently).\n * - Otherwise, the key is derived deterministically via HMAC-SHA256 from the base secret using a\n * versioned label. This ensures that a staff JWT signature cannot verify against the customer\n * key (and vice versa) even though both share the same base `JWT_SECRET`.\n * - Derived values are memoized per `(audience, baseSecret)` for the process lifetime.\n */\nexport function deriveJwtAudienceSecret(audience: string, baseSecret?: string): string {\n const normalized = normalizeAudience(audience)\n if (!normalized) throw new Error('Audience is required to derive a JWT secret')\n const overrideName = `JWT_${normalized.toUpperCase()}_SECRET`\n const override = process.env[overrideName]\n if (override && override.trim().length > 0) return override\n const base = readBaseSecret(baseSecret)\n return deriveAudienceSecretFromBase(normalized, base)\n}\n\nfunction isSignOptions(value: string | SignJwtOptions | undefined): value is SignJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction isVerifyOptions(value: string | VerifyJwtOptions | undefined): value is VerifyJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction toSignOptions(secretOrOptions?: string | SignJwtOptions, expiresInSec?: number): { secret: string; expiresInSec: number; audience?: string; issuer?: string } {\n if (isSignOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n expiresInSec: secretOrOptions.expiresInSec ?? 60 * 60 * 8,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n // Legacy: explicit raw secret supplied by caller \u2014 keep audience/issuer off by default so\n // existing tests and callers that BYO secret see unchanged behavior.\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n return {\n secret: secretOrOptions,\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n }\n }\n // Default path: staff-audience derived secret + iss/aud claims.\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nfunction toVerifyOptions(secretOrOptions?: string | VerifyJwtOptions): { secret: string; audience?: string; issuer?: string } {\n if (isVerifyOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n // Legacy explicit secret: no audience/issuer enforcement.\n return { secret: secretOrOptions }\n }\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nexport function signJwt(\n payload: JwtPayload,\n secretOrOptions?: string | SignJwtOptions,\n expiresInSec?: number,\n) {\n const options = toSignOptions(secretOrOptions, expiresInSec)\n const header = { alg: 'HS256', typ: 'JWT' }\n const now = Math.floor(Date.now() / 1000)\n const body: JwtPayload = { iat: now, exp: now + options.expiresInSec, ...payload }\n if (options.issuer && body.iss === undefined) body.iss = options.issuer\n if (options.audience && body.aud === undefined) body.aud = options.audience\n const encHeader = base64url(JSON.stringify(header))\n const encBody = base64url(JSON.stringify(body))\n const data = `${encHeader}.${encBody}`\n const sig = crypto.createHmac('sha256', options.secret).update(data).digest()\n const encSig = base64url(sig)\n return `${data}.${encSig}`\n}\n\nfunction verifyWithOptions(token: string, options: { secret: string; audience?: string; issuer?: string }): JwtPayload | null {\n const parts = token.split('.')\n if (parts.length !== 3) return null\n const [h, p, s] = parts\n const data = `${h}.${p}`\n const expected = base64url(crypto.createHmac('sha256', options.secret).update(data).digest())\n const providedSignature = Buffer.from(s)\n const expectedSignature = Buffer.from(expected)\n if (providedSignature.length !== expectedSignature.length) return null\n if (!crypto.timingSafeEqual(providedSignature, expectedSignature)) return null\n let payload: JwtPayload\n try {\n payload = JSON.parse(Buffer.from(p, 'base64').toString('utf8'))\n } catch {\n return null\n }\n const now = Math.floor(Date.now() / 1000)\n if (payload.exp && now > payload.exp) return null\n if (options.audience !== undefined) {\n if (payload.aud !== options.audience) return null\n }\n if (options.issuer !== undefined) {\n if (payload.iss !== options.issuer) return null\n }\n return payload\n}\n\nexport function verifyJwt(token: string, secretOrOptions?: string | VerifyJwtOptions) {\n const options = toVerifyOptions(secretOrOptions)\n const result = verifyWithOptions(token, options)\n if (result) return result\n\n // Legacy fallback: when the caller used the default path (no explicit secret) and the new\n // audience-derived verification failed, try verifying with the raw JWT_SECRET. This allows\n // pre-migration tokens to remain valid during rolling deployments and graceful migration.\n if (secretOrOptions === undefined && getLegacyGraceEnabled()) {\n const rawSecret = process.env.JWT_SECRET\n if (rawSecret) {\n const legacyResult = verifyWithOptions(token, { secret: rawSecret })\n if (legacyResult) {\n legacyResult._legacyToken = true\n return legacyResult\n }\n }\n }\n\n return null\n}\n\n/**\n * Sign a JWT for a specific audience using an audience-derived signing key. The resulting token\n * carries `iss` and `aud` claims and cannot be verified with the base `JWT_SECRET` directly \u2014\n * callers must use `verifyAudienceJwt` with the same audience.\n */\nexport function signAudienceJwt(\n audience: string,\n payload: JwtPayload,\n expiresInSec: number = 60 * 60 * 8,\n): string {\n return signJwt(payload, { audience, expiresInSec })\n}\n\n/**\n * Verify a JWT that was signed with an audience-scoped secret. Rejects tokens that are missing\n * or carry a mismatched `aud`/`iss` claim, so a staff JWT cannot be replayed against the\n * customer portal (and vice versa) even when the base `JWT_SECRET` is shared.\n */\nexport function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {\n return verifyJwt(token, { audience })\n}\n"],
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '../logger'\nimport { parseNumberWithDefault } from '../number'\n\nconst logger = createLogger('auth').child({ component: 'jwt' })\n\nfunction base64url(input: Buffer | string) {\n return (typeof input === 'string' ? Buffer.from(input) : input)\n .toString('base64')\n .replace(/=/g, '')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n}\n\nexport type JwtPayload = Record<string, any>\n\nexport type JwtAudience = 'staff' | 'customer' | (string & {})\n\nexport type SignJwtOptions = {\n secret?: string\n expiresInSec?: number\n audience?: string\n issuer?: string\n}\n\nexport type VerifyJwtOptions = {\n secret?: string\n audience?: string\n issuer?: string\n}\n\nconst DEFAULT_ISSUER = 'open-mercato'\nconst DEFAULT_STAFF_AUDIENCE: JwtAudience = 'staff'\nconst AUDIENCE_SECRET_LABEL = 'open-mercato:jwt:v1'\n\nconst LEGACY_GRACE_DEFAULT_MINUTES = 480\nconst LEGACY_TOKEN_CLOCK_SKEW_SECONDS = 60\n\n/**\n * How long after a token was issued (`iat`) the raw-`JWT_SECRET` fallback in `verifyJwt` keeps\n * accepting it. The fallback exists so a rolling deployment of the audience-derived signing\n * scheme does not force-log-out every user; it is a migration window, not a permanent mode.\n *\n * Set via `JWT_LEGACY_GRACE_MINUTES`. Defaults to 480 (8 hours \u2014 one full token TTL).\n * `0`, `false` or `off` disables the fallback entirely (hard cutover). Values that do not parse\n * fall back to the default rather than silently disabling authentication.\n */\nfunction getLegacyGraceMinutes(): number {\n const raw = process.env.JWT_LEGACY_GRACE_MINUTES\n const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : raw\n if (normalized === 'false' || normalized === 'off') return 0\n return parseNumberWithDefault(normalized, LEGACY_GRACE_DEFAULT_MINUTES, { min: 0, integer: true })\n}\n\n/**\n * Required absolute deadline for the legacy fallback, as an ISO-8601 instant in\n * `JWT_LEGACY_CUTOVER_AT`. Without a valid deadline the fallback stays disabled: token `iat` is\n * attacker-controlled by anyone who knows the former raw secret, so a relative age check alone\n * cannot make the migration window finite.\n */\nfunction getLegacyCutoverEpochSeconds(): number | null {\n const raw = process.env.JWT_LEGACY_CUTOVER_AT\n if (!raw || !raw.trim()) return null\n const parsed = Date.parse(raw.trim())\n if (Number.isNaN(parsed)) {\n warnOnce(\n 'jwt-legacy-cutover-unparseable',\n 'JWT_LEGACY_CUTOVER_AT is not a valid ISO-8601 instant \u2014 legacy JWT fallback remains disabled.',\n )\n return null\n }\n return Math.floor(parsed / 1000)\n}\n\nconst MIN_SECRET_LENGTH = 32\n\n/**\n * Signing secrets that ship in this repository's own examples, compose files, and docs. A\n * deployment reaching production with one of these is not \"weakly configured\" \u2014 it is publicly\n * forgeable by anyone who has read the repository.\n */\nconst PLACEHOLDER_SECRETS = new Set([\n 'jwt',\n 'jwt-secret',\n 'jwtsecret',\n 'secret',\n 'password',\n 'changeme',\n 'change-me',\n 'change-me-dev-secret',\n 'change-me-dev-auth-secret',\n 'your-strong-jwt-secret',\n 'your-secure-jwt-secret-change-me',\n 'dev',\n 'development',\n 'test',\n])\n\nexport type JwtSecretViolation = 'missing' | 'placeholder' | 'too_short'\n\nconst warnedKeys = new Set<string>()\n\nfunction warnOnce(key: string, message: string): void {\n if (warnedKeys.has(key)) return\n warnedKeys.add(key)\n logger.warn(message)\n}\n\nfunction isProduction(): boolean {\n return process.env.NODE_ENV === 'production'\n}\n\nfunction inspectSecret(secret: string | undefined | null): JwtSecretViolation | null {\n const value = typeof secret === 'string' ? secret.trim() : ''\n if (!value) return 'missing'\n if (PLACEHOLDER_SECRETS.has(value.toLowerCase())) return 'placeholder'\n if (value.length < MIN_SECRET_LENGTH) return 'too_short'\n return null\n}\n\nfunction describeViolation(name: string, violation: JwtSecretViolation): string {\n switch (violation) {\n case 'missing':\n return `${name} is not set. Generate one with \\`openssl rand -hex 32\\`.`\n case 'placeholder':\n return `${name} is set to a placeholder value published in this repository's examples, so anyone can forge tokens for this deployment. Generate a real one with \\`openssl rand -hex 32\\`.`\n case 'too_short':\n return `${name} is shorter than ${MIN_SECRET_LENGTH} characters. Generate a stronger one with \\`openssl rand -hex 32\\`.`\n }\n}\n\n/**\n * Fail closed in production, warn in every other environment. Called on every secret read so\n * worker, scheduler, and CLI processes \u2014 which never run the app's startup hook \u2014 are covered\n * too. `assertJwtSecretPolicy` runs the same check eagerly at server startup.\n */\nfunction enforceSecretPolicy(name: string, secret: string | undefined | null): void {\n const violation = inspectSecret(secret)\n if (!violation) return\n const message = describeViolation(name, violation)\n if (isProduction()) {\n throw new Error(`[auth.jwt] Refusing to run in production with an unsafe signing secret: ${message}`)\n }\n warnOnce(`secret-policy:${name}:${violation}`, `${message} This is tolerated outside production only.`)\n}\n\n/**\n * Validate every JWT signing secret this process would use. Call it once at startup so a\n * misconfigured production deployment fails immediately and loudly instead of at the first login\n * attempt. Throws in production; logs a warning elsewhere.\n */\nexport function assertJwtSecretPolicy(): void {\n enforceSecretPolicy('JWT_SECRET', process.env.JWT_SECRET)\n for (const [key, value] of Object.entries(process.env)) {\n if (!/^JWT_[A-Z0-9]+(?:_[A-Z0-9]+)*_SECRET$/.test(key)) continue\n enforceSecretPolicy(key, value)\n }\n}\n\nfunction readBaseSecret(explicit?: string): string {\n const secret = explicit ?? process.env.JWT_SECRET\n if (!secret) throw new Error('JWT_SECRET is not set')\n if (explicit === undefined) enforceSecretPolicy('JWT_SECRET', secret)\n return secret\n}\n\nfunction normalizeAudience(audience: string): string {\n return audience.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')\n}\n\nconst derivedSecretCache = new Map<string, string>()\n\nfunction deriveAudienceSecretFromBase(normalized: string, base: string): string {\n const cacheKey = `${normalized}::${base}`\n const cached = derivedSecretCache.get(cacheKey)\n if (cached !== undefined) return cached\n const label = `${AUDIENCE_SECRET_LABEL}:${normalized}`\n const derived = crypto.createHmac('sha256', base).update(label).digest('hex')\n derivedSecretCache.set(cacheKey, derived)\n return derived\n}\n\n/**\n * Derive a per-audience signing key from the base `JWT_SECRET`.\n *\n * - If `JWT_${AUDIENCE}_SECRET` env var is set, it is used verbatim (allows operators to rotate a\n * single audience independently).\n * - Otherwise, the key is derived deterministically via HMAC-SHA256 from the base secret using a\n * versioned label. This ensures that a staff JWT signature cannot verify against the customer\n * key (and vice versa) even though both share the same base `JWT_SECRET`.\n * - Derived values are memoized per `(audience, baseSecret)` for the process lifetime.\n */\nexport function deriveJwtAudienceSecret(audience: string, baseSecret?: string): string {\n const normalized = normalizeAudience(audience)\n if (!normalized) throw new Error('Audience is required to derive a JWT secret')\n const overrideName = `JWT_${normalized.toUpperCase()}_SECRET`\n const override = process.env[overrideName]\n if (override && override.trim().length > 0) {\n enforceSecretPolicy(overrideName, override)\n return override\n }\n const base = readBaseSecret(baseSecret)\n return deriveAudienceSecretFromBase(normalized, base)\n}\n\nfunction isSignOptions(value: string | SignJwtOptions | undefined): value is SignJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction isVerifyOptions(value: string | VerifyJwtOptions | undefined): value is VerifyJwtOptions {\n return typeof value === 'object' && value !== null\n}\n\nfunction toSignOptions(secretOrOptions?: string | SignJwtOptions, expiresInSec?: number): { secret: string; expiresInSec: number; audience?: string; issuer?: string } {\n if (isSignOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n expiresInSec: secretOrOptions.expiresInSec ?? 60 * 60 * 8,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n // Legacy: explicit raw secret supplied by caller \u2014 keep audience/issuer off by default so\n // existing tests and callers that BYO secret see unchanged behavior.\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n return {\n secret: secretOrOptions,\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n }\n }\n // Default path: staff-audience derived secret + iss/aud claims.\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n expiresInSec: expiresInSec ?? 60 * 60 * 8,\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nfunction toVerifyOptions(secretOrOptions?: string | VerifyJwtOptions): { secret: string; audience?: string; issuer?: string } {\n if (isVerifyOptions(secretOrOptions)) {\n const audience = secretOrOptions.audience ?? DEFAULT_STAFF_AUDIENCE\n const secret = secretOrOptions.secret ?? deriveJwtAudienceSecret(audience)\n if (!secret) throw new Error('JWT_SECRET is not set')\n return {\n secret,\n audience,\n issuer: secretOrOptions.issuer ?? DEFAULT_ISSUER,\n }\n }\n if (typeof secretOrOptions === 'string') {\n if (!secretOrOptions) throw new Error('JWT_SECRET is not set')\n // Legacy explicit secret: no audience/issuer enforcement.\n return { secret: secretOrOptions }\n }\n return {\n secret: deriveJwtAudienceSecret(DEFAULT_STAFF_AUDIENCE),\n audience: DEFAULT_STAFF_AUDIENCE,\n issuer: DEFAULT_ISSUER,\n }\n}\n\nexport function signJwt(\n payload: JwtPayload,\n secretOrOptions?: string | SignJwtOptions,\n expiresInSec?: number,\n) {\n const options = toSignOptions(secretOrOptions, expiresInSec)\n const header = { alg: 'HS256', typ: 'JWT' }\n const now = Math.floor(Date.now() / 1000)\n const body: JwtPayload = { iat: now, exp: now + options.expiresInSec, ...payload }\n if (options.issuer && body.iss === undefined) body.iss = options.issuer\n if (options.audience && body.aud === undefined) body.aud = options.audience\n const encHeader = base64url(JSON.stringify(header))\n const encBody = base64url(JSON.stringify(body))\n const data = `${encHeader}.${encBody}`\n const sig = crypto.createHmac('sha256', options.secret).update(data).digest()\n const encSig = base64url(sig)\n return `${data}.${encSig}`\n}\n\nfunction verifyWithOptions(token: string, options: { secret: string; audience?: string; issuer?: string }): JwtPayload | null {\n const parts = token.split('.')\n if (parts.length !== 3) return null\n const [h, p, s] = parts\n const data = `${h}.${p}`\n const expected = base64url(crypto.createHmac('sha256', options.secret).update(data).digest())\n const providedSignature = Buffer.from(s)\n const expectedSignature = Buffer.from(expected)\n if (providedSignature.length !== expectedSignature.length) return null\n if (!crypto.timingSafeEqual(providedSignature, expectedSignature)) return null\n let payload: JwtPayload\n try {\n payload = JSON.parse(Buffer.from(p, 'base64').toString('utf8'))\n } catch {\n return null\n }\n const now = Math.floor(Date.now() / 1000)\n if (payload.exp && now > payload.exp) return null\n if (options.audience !== undefined) {\n if (payload.aud !== options.audience) return null\n }\n if (options.issuer !== undefined) {\n if (payload.iss !== options.issuer) return null\n }\n return payload\n}\n\n/**\n * Whether a raw-secret token is still inside the migration window. A token is only ever legacy\n * for a bounded period after it was issued, so `iat` is mandatory: a token that cannot prove its\n * age cannot prove it is inside the window either, and accepting it would make the window\n * unbounded \u2014 which is exactly the defect this guard closes.\n */\nfunction isWithinLegacyWindow(payload: JwtPayload, graceMinutes: number): boolean {\n const cutoverAt = getLegacyCutoverEpochSeconds()\n const now = Math.floor(Date.now() / 1000)\n if (cutoverAt === null || now >= cutoverAt) return false\n const issuedAt = payload.iat\n if (typeof issuedAt !== 'number' || !Number.isFinite(issuedAt)) return false\n if (issuedAt > now + LEGACY_TOKEN_CLOCK_SKEW_SECONDS) return false\n return now - issuedAt <= graceMinutes * 60\n}\n\nexport function verifyJwt(token: string, secretOrOptions?: string | VerifyJwtOptions) {\n const options = toVerifyOptions(secretOrOptions)\n const result = verifyWithOptions(token, options)\n if (result) {\n // `_legacyToken` is assigned by this function alone. Strip any same-named claim carried in\n // the token body so a payload can never talk callers (staff session integrity, portal auth)\n // into treating a modern, session-bound token as a sessionless legacy one.\n if (result._legacyToken !== undefined) delete result._legacyToken\n return result\n }\n\n // Legacy fallback: when the caller used the default path (no explicit secret) and the new\n // audience-derived verification failed, try verifying with the raw JWT_SECRET. This keeps\n // pre-migration tokens working across a rolling deployment \u2014 but only until the token's own\n // `iat` leaves the configured grace window, or the configured cutover instant passes.\n if (secretOrOptions === undefined) {\n const graceMinutes = getLegacyGraceMinutes()\n const rawSecret = process.env.JWT_SECRET\n if (graceMinutes > 0 && rawSecret) {\n const legacyResult = verifyWithOptions(token, { secret: rawSecret })\n if (legacyResult && isWithinLegacyWindow(legacyResult, graceMinutes)) {\n legacyResult._legacyToken = true\n return legacyResult\n }\n }\n }\n\n return null\n}\n\n/**\n * Sign a JWT for a specific audience using an audience-derived signing key. The resulting token\n * carries `iss` and `aud` claims and cannot be verified with the base `JWT_SECRET` directly \u2014\n * callers must use `verifyAudienceJwt` with the same audience.\n */\nexport function signAudienceJwt(\n audience: string,\n payload: JwtPayload,\n expiresInSec: number = 60 * 60 * 8,\n): string {\n return signJwt(payload, { audience, expiresInSec })\n}\n\n/**\n * Verify a JWT that was signed with an audience-scoped secret. Rejects tokens that are missing\n * or carry a mismatched `aud`/`iss` claim, so a staff JWT cannot be replayed against the\n * customer portal (and vice versa) even when the base `JWT_SECRET` is shared.\n */\nexport function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {\n return verifyJwt(token, { audience })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6948.1.8369fc4c97';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6948.1.8369fc4c97",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@mikro-orm/core": "^7.1.8",
|
|
106
106
|
"@mikro-orm/decorators": "^7.1.8",
|
|
107
107
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
108
|
-
"@open-mercato/cache": "0.6.8-develop.
|
|
108
|
+
"@open-mercato/cache": "0.6.8-develop.6948.1.8369fc4c97",
|
|
109
109
|
"@types/sanitize-html": "^2.16.1",
|
|
110
110
|
"dotenv": "^17.4.2",
|
|
111
111
|
"pino": "^10.3.1",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
assertJwtSecretPolicy,
|
|
4
5
|
deriveJwtAudienceSecret,
|
|
5
6
|
signAudienceJwt,
|
|
6
7
|
signJwt,
|
|
@@ -233,18 +234,26 @@ describe('jwt helpers', () => {
|
|
|
233
234
|
const baseSecret = 'test-secret'
|
|
234
235
|
const originalJwtSecret = process.env.JWT_SECRET
|
|
235
236
|
const originalGrace = process.env.JWT_LEGACY_GRACE_MINUTES
|
|
237
|
+
const originalCutover = process.env.JWT_LEGACY_CUTOVER_AT
|
|
236
238
|
|
|
237
239
|
beforeEach(() => {
|
|
238
240
|
process.env.JWT_SECRET = baseSecret
|
|
239
241
|
delete process.env.JWT_LEGACY_GRACE_MINUTES
|
|
242
|
+
process.env.JWT_LEGACY_CUTOVER_AT = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString()
|
|
240
243
|
})
|
|
241
244
|
|
|
242
245
|
afterEach(() => {
|
|
243
246
|
process.env.JWT_SECRET = originalJwtSecret
|
|
244
247
|
if (originalGrace === undefined) delete process.env.JWT_LEGACY_GRACE_MINUTES
|
|
245
248
|
else process.env.JWT_LEGACY_GRACE_MINUTES = originalGrace
|
|
249
|
+
if (originalCutover === undefined) delete process.env.JWT_LEGACY_CUTOVER_AT
|
|
250
|
+
else process.env.JWT_LEGACY_CUTOVER_AT = originalCutover
|
|
246
251
|
})
|
|
247
252
|
|
|
253
|
+
function advanceMinutes(minutes: number): void {
|
|
254
|
+
jest.spyOn(Date, 'now').mockReturnValue(now.getTime() + minutes * 60 * 1000)
|
|
255
|
+
}
|
|
256
|
+
|
|
248
257
|
it('verifies a pre-migration token signed with raw JWT_SECRET via legacy fallback', () => {
|
|
249
258
|
// Simulate a pre-migration token: signed with raw secret, no aud/iss
|
|
250
259
|
const legacyToken = signJwt({ sub: 'legacy-user', roles: ['admin'] }, baseSecret, 3600)
|
|
@@ -275,5 +284,189 @@ describe('jwt helpers', () => {
|
|
|
275
284
|
expect(payload).toMatchObject({ sub: 'new-user', aud: 'staff', iss: 'open-mercato' })
|
|
276
285
|
expect((payload as Record<string, unknown>)._legacyToken).toBeUndefined()
|
|
277
286
|
})
|
|
287
|
+
|
|
288
|
+
it('rejects a legacy token issued longer ago than the configured grace window', () => {
|
|
289
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = '1'
|
|
290
|
+
// Long TTL so `exp` is still in the future: this must fail on the grace window alone,
|
|
291
|
+
// otherwise the test would pass even with an unbounded fallback.
|
|
292
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 30 * 24 * 3600)
|
|
293
|
+
expect(verifyJwt(legacyToken)).not.toBeNull()
|
|
294
|
+
advanceMinutes(120)
|
|
295
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('keeps accepting a legacy token while it is still inside the grace window', () => {
|
|
299
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = '60'
|
|
300
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 30 * 24 * 3600)
|
|
301
|
+
advanceMinutes(59)
|
|
302
|
+
const payload = verifyJwt(legacyToken)
|
|
303
|
+
expect(payload).not.toBeNull()
|
|
304
|
+
expect((payload as Record<string, unknown>)._legacyToken).toBe(true)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('rejects a legacy token that carries no iat, since its age cannot be bounded', () => {
|
|
308
|
+
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
|
309
|
+
const body = base64url(JSON.stringify({ sub: 'legacy-user', exp: Math.floor(now.getTime() / 1000) + 3600 }))
|
|
310
|
+
const token = `${header}.${body}.${signTokenParts(header, body, baseSecret)}`
|
|
311
|
+
expect(verifyJwt(token)).toBeNull()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('rejects legacy fallback unless an explicit absolute cutover is configured', () => {
|
|
315
|
+
delete process.env.JWT_LEGACY_CUTOVER_AT
|
|
316
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 30 * 24 * 3600)
|
|
317
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('rejects a legacy token issued beyond the allowed clock skew', () => {
|
|
321
|
+
const futureIssuedAt = Math.floor(now.getTime() / 1000) + 61
|
|
322
|
+
const legacyToken = signJwt(
|
|
323
|
+
{ sub: 'legacy-user', iat: futureIssuedAt },
|
|
324
|
+
baseSecret,
|
|
325
|
+
30 * 24 * 3600,
|
|
326
|
+
)
|
|
327
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('accepts one minute of issuer clock skew during an explicitly bounded migration', () => {
|
|
331
|
+
const futureIssuedAt = Math.floor(now.getTime() / 1000) + 60
|
|
332
|
+
const legacyToken = signJwt(
|
|
333
|
+
{ sub: 'legacy-user', iat: futureIssuedAt },
|
|
334
|
+
baseSecret,
|
|
335
|
+
30 * 24 * 3600,
|
|
336
|
+
)
|
|
337
|
+
expect(verifyJwt(legacyToken)).not.toBeNull()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('rejects legacy tokens once the configured cutover instant has passed', () => {
|
|
341
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = '480'
|
|
342
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 30 * 24 * 3600)
|
|
343
|
+
process.env.JWT_LEGACY_CUTOVER_AT = new Date(now.getTime() + 60 * 60 * 1000).toISOString()
|
|
344
|
+
advanceMinutes(30)
|
|
345
|
+
expect(verifyJwt(legacyToken)).not.toBeNull()
|
|
346
|
+
advanceMinutes(90)
|
|
347
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('treats false and off as a disabled fallback, like 0', () => {
|
|
351
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 3600)
|
|
352
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = 'false'
|
|
353
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
354
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = 'off'
|
|
355
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('accepts a legacy token at exactly the edge of the grace window', () => {
|
|
359
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = '60'
|
|
360
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 30 * 24 * 3600)
|
|
361
|
+
advanceMinutes(60)
|
|
362
|
+
expect(verifyJwt(legacyToken)).not.toBeNull()
|
|
363
|
+
advanceMinutes(61)
|
|
364
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('rejects a legacy token at exactly the cutover instant, not a second later', () => {
|
|
368
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = '480'
|
|
369
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 30 * 24 * 3600)
|
|
370
|
+
process.env.JWT_LEGACY_CUTOVER_AT = new Date(now.getTime() + 60 * 60 * 1000).toISOString()
|
|
371
|
+
advanceMinutes(59)
|
|
372
|
+
expect(verifyJwt(legacyToken)).not.toBeNull()
|
|
373
|
+
advanceMinutes(60)
|
|
374
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('normalizes the grace value for case and surrounding whitespace', () => {
|
|
378
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 3600)
|
|
379
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = ' OFF '
|
|
380
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
381
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = ' False '
|
|
382
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('disables legacy fallback when the configured cutover is unparseable', () => {
|
|
386
|
+
process.env.JWT_LEGACY_GRACE_MINUTES = '60'
|
|
387
|
+
process.env.JWT_LEGACY_CUTOVER_AT = 'not-a-date'
|
|
388
|
+
const legacyToken = signJwt({ sub: 'legacy-user' }, baseSecret, 30 * 24 * 3600)
|
|
389
|
+
expect(verifyJwt(legacyToken)).toBeNull()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('ignores a _legacyToken claim smuggled into a modern token payload', () => {
|
|
393
|
+
const token = signJwt({ sub: 'staff-user', _legacyToken: true })
|
|
394
|
+
const payload = verifyJwt(token) as Record<string, unknown> | null
|
|
395
|
+
expect(payload).not.toBeNull()
|
|
396
|
+
expect(payload?._legacyToken).toBeUndefined()
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
describe('signing secret policy', () => {
|
|
401
|
+
const originalJwtSecret = process.env.JWT_SECRET
|
|
402
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
403
|
+
const strongSecret = 'a'.repeat(64)
|
|
404
|
+
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
process.env.JWT_SECRET = originalJwtSecret
|
|
407
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
408
|
+
delete process.env.JWT_CUSTOMER_SECRET
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('refuses to sign in production with the placeholder secret published in the compose files', () => {
|
|
412
|
+
process.env.NODE_ENV = 'production'
|
|
413
|
+
process.env.JWT_SECRET = 'JWT'
|
|
414
|
+
// Assert the reason, not merely that it threw: `JWT` is also under the length floor, so a
|
|
415
|
+
// policy that had stopped recognizing placeholders would still throw here for the wrong
|
|
416
|
+
// reason and the test would pass while the placeholder set had quietly become dead code.
|
|
417
|
+
expect(() => signJwt({ sub: 'user-1' })).toThrow(/placeholder value published/i)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('refuses the 32-character placeholder shipped in the previous production guide', () => {
|
|
421
|
+
process.env.NODE_ENV = 'production'
|
|
422
|
+
process.env.JWT_SECRET = 'your-secure-jwt-secret-change-me'
|
|
423
|
+
expect(() => signJwt({ sub: 'user-1' })).toThrow(/placeholder value published/i)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('refuses to sign in production with a secret shorter than 32 characters', () => {
|
|
427
|
+
process.env.NODE_ENV = 'production'
|
|
428
|
+
process.env.JWT_SECRET = 'short-secret'
|
|
429
|
+
expect(() => signJwt({ sub: 'user-1' })).toThrow(/unsafe signing secret/i)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('accepts a secret of exactly the minimum length and rejects one character less', () => {
|
|
433
|
+
process.env.NODE_ENV = 'production'
|
|
434
|
+
process.env.JWT_SECRET = 'a'.repeat(32)
|
|
435
|
+
expect(() => signJwt({ sub: 'user-1' })).not.toThrow()
|
|
436
|
+
process.env.JWT_SECRET = 'a'.repeat(31)
|
|
437
|
+
expect(() => signJwt({ sub: 'user-1' })).toThrow(/shorter than 32 characters/i)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('signs normally in production with a strong secret', () => {
|
|
441
|
+
process.env.NODE_ENV = 'production'
|
|
442
|
+
process.env.JWT_SECRET = strongSecret
|
|
443
|
+
expect(() => signJwt({ sub: 'user-1' })).not.toThrow()
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('tolerates a weak secret outside production so local development keeps working', () => {
|
|
447
|
+
process.env.NODE_ENV = 'development'
|
|
448
|
+
process.env.JWT_SECRET = 'JWT'
|
|
449
|
+
expect(() => signJwt({ sub: 'user-1' })).not.toThrow()
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('assertJwtSecretPolicy rejects a missing secret in production', () => {
|
|
453
|
+
process.env.NODE_ENV = 'production'
|
|
454
|
+
delete process.env.JWT_SECRET
|
|
455
|
+
expect(() => assertJwtSecretPolicy()).toThrow(/JWT_SECRET is not set/i)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('assertJwtSecretPolicy rejects a weak per-audience override in production', () => {
|
|
459
|
+
process.env.NODE_ENV = 'production'
|
|
460
|
+
process.env.JWT_SECRET = strongSecret
|
|
461
|
+
process.env.JWT_CUSTOMER_SECRET = 'secret'
|
|
462
|
+
expect(() => assertJwtSecretPolicy()).toThrow(/JWT_CUSTOMER_SECRET/)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('assertJwtSecretPolicy passes in production when every secret is strong', () => {
|
|
466
|
+
process.env.NODE_ENV = 'production'
|
|
467
|
+
process.env.JWT_SECRET = strongSecret
|
|
468
|
+
process.env.JWT_CUSTOMER_SECRET = 'b'.repeat(64)
|
|
469
|
+
expect(() => assertJwtSecretPolicy()).not.toThrow()
|
|
470
|
+
})
|
|
278
471
|
})
|
|
279
472
|
})
|
package/src/lib/auth/jwt.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
|
+
import { createLogger } from '../logger'
|
|
3
|
+
import { parseNumberWithDefault } from '../number'
|
|
4
|
+
|
|
5
|
+
const logger = createLogger('auth').child({ component: 'jwt' })
|
|
2
6
|
|
|
3
7
|
function base64url(input: Buffer | string) {
|
|
4
8
|
return (typeof input === 'string' ? Buffer.from(input) : input)
|
|
@@ -29,23 +33,134 @@ const DEFAULT_ISSUER = 'open-mercato'
|
|
|
29
33
|
const DEFAULT_STAFF_AUDIENCE: JwtAudience = 'staff'
|
|
30
34
|
const AUDIENCE_SECRET_LABEL = 'open-mercato:jwt:v1'
|
|
31
35
|
|
|
36
|
+
const LEGACY_GRACE_DEFAULT_MINUTES = 480
|
|
37
|
+
const LEGACY_TOKEN_CLOCK_SKEW_SECONDS = 60
|
|
38
|
+
|
|
32
39
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
40
|
+
* How long after a token was issued (`iat`) the raw-`JWT_SECRET` fallback in `verifyJwt` keeps
|
|
41
|
+
* accepting it. The fallback exists so a rolling deployment of the audience-derived signing
|
|
42
|
+
* scheme does not force-log-out every user; it is a migration window, not a permanent mode.
|
|
36
43
|
*
|
|
37
|
-
* Set via `JWT_LEGACY_GRACE_MINUTES
|
|
38
|
-
*
|
|
44
|
+
* Set via `JWT_LEGACY_GRACE_MINUTES`. Defaults to 480 (8 hours — one full token TTL).
|
|
45
|
+
* `0`, `false` or `off` disables the fallback entirely (hard cutover). Values that do not parse
|
|
46
|
+
* fall back to the default rather than silently disabling authentication.
|
|
39
47
|
*/
|
|
40
|
-
function
|
|
48
|
+
function getLegacyGraceMinutes(): number {
|
|
41
49
|
const raw = process.env.JWT_LEGACY_GRACE_MINUTES
|
|
42
|
-
|
|
43
|
-
return
|
|
50
|
+
const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : raw
|
|
51
|
+
if (normalized === 'false' || normalized === 'off') return 0
|
|
52
|
+
return parseNumberWithDefault(normalized, LEGACY_GRACE_DEFAULT_MINUTES, { min: 0, integer: true })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Required absolute deadline for the legacy fallback, as an ISO-8601 instant in
|
|
57
|
+
* `JWT_LEGACY_CUTOVER_AT`. Without a valid deadline the fallback stays disabled: token `iat` is
|
|
58
|
+
* attacker-controlled by anyone who knows the former raw secret, so a relative age check alone
|
|
59
|
+
* cannot make the migration window finite.
|
|
60
|
+
*/
|
|
61
|
+
function getLegacyCutoverEpochSeconds(): number | null {
|
|
62
|
+
const raw = process.env.JWT_LEGACY_CUTOVER_AT
|
|
63
|
+
if (!raw || !raw.trim()) return null
|
|
64
|
+
const parsed = Date.parse(raw.trim())
|
|
65
|
+
if (Number.isNaN(parsed)) {
|
|
66
|
+
warnOnce(
|
|
67
|
+
'jwt-legacy-cutover-unparseable',
|
|
68
|
+
'JWT_LEGACY_CUTOVER_AT is not a valid ISO-8601 instant — legacy JWT fallback remains disabled.',
|
|
69
|
+
)
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
return Math.floor(parsed / 1000)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const MIN_SECRET_LENGTH = 32
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Signing secrets that ship in this repository's own examples, compose files, and docs. A
|
|
79
|
+
* deployment reaching production with one of these is not "weakly configured" — it is publicly
|
|
80
|
+
* forgeable by anyone who has read the repository.
|
|
81
|
+
*/
|
|
82
|
+
const PLACEHOLDER_SECRETS = new Set([
|
|
83
|
+
'jwt',
|
|
84
|
+
'jwt-secret',
|
|
85
|
+
'jwtsecret',
|
|
86
|
+
'secret',
|
|
87
|
+
'password',
|
|
88
|
+
'changeme',
|
|
89
|
+
'change-me',
|
|
90
|
+
'change-me-dev-secret',
|
|
91
|
+
'change-me-dev-auth-secret',
|
|
92
|
+
'your-strong-jwt-secret',
|
|
93
|
+
'your-secure-jwt-secret-change-me',
|
|
94
|
+
'dev',
|
|
95
|
+
'development',
|
|
96
|
+
'test',
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
export type JwtSecretViolation = 'missing' | 'placeholder' | 'too_short'
|
|
100
|
+
|
|
101
|
+
const warnedKeys = new Set<string>()
|
|
102
|
+
|
|
103
|
+
function warnOnce(key: string, message: string): void {
|
|
104
|
+
if (warnedKeys.has(key)) return
|
|
105
|
+
warnedKeys.add(key)
|
|
106
|
+
logger.warn(message)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isProduction(): boolean {
|
|
110
|
+
return process.env.NODE_ENV === 'production'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function inspectSecret(secret: string | undefined | null): JwtSecretViolation | null {
|
|
114
|
+
const value = typeof secret === 'string' ? secret.trim() : ''
|
|
115
|
+
if (!value) return 'missing'
|
|
116
|
+
if (PLACEHOLDER_SECRETS.has(value.toLowerCase())) return 'placeholder'
|
|
117
|
+
if (value.length < MIN_SECRET_LENGTH) return 'too_short'
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function describeViolation(name: string, violation: JwtSecretViolation): string {
|
|
122
|
+
switch (violation) {
|
|
123
|
+
case 'missing':
|
|
124
|
+
return `${name} is not set. Generate one with \`openssl rand -hex 32\`.`
|
|
125
|
+
case 'placeholder':
|
|
126
|
+
return `${name} is set to a placeholder value published in this repository's examples, so anyone can forge tokens for this deployment. Generate a real one with \`openssl rand -hex 32\`.`
|
|
127
|
+
case 'too_short':
|
|
128
|
+
return `${name} is shorter than ${MIN_SECRET_LENGTH} characters. Generate a stronger one with \`openssl rand -hex 32\`.`
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Fail closed in production, warn in every other environment. Called on every secret read so
|
|
134
|
+
* worker, scheduler, and CLI processes — which never run the app's startup hook — are covered
|
|
135
|
+
* too. `assertJwtSecretPolicy` runs the same check eagerly at server startup.
|
|
136
|
+
*/
|
|
137
|
+
function enforceSecretPolicy(name: string, secret: string | undefined | null): void {
|
|
138
|
+
const violation = inspectSecret(secret)
|
|
139
|
+
if (!violation) return
|
|
140
|
+
const message = describeViolation(name, violation)
|
|
141
|
+
if (isProduction()) {
|
|
142
|
+
throw new Error(`[auth.jwt] Refusing to run in production with an unsafe signing secret: ${message}`)
|
|
143
|
+
}
|
|
144
|
+
warnOnce(`secret-policy:${name}:${violation}`, `${message} This is tolerated outside production only.`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validate every JWT signing secret this process would use. Call it once at startup so a
|
|
149
|
+
* misconfigured production deployment fails immediately and loudly instead of at the first login
|
|
150
|
+
* attempt. Throws in production; logs a warning elsewhere.
|
|
151
|
+
*/
|
|
152
|
+
export function assertJwtSecretPolicy(): void {
|
|
153
|
+
enforceSecretPolicy('JWT_SECRET', process.env.JWT_SECRET)
|
|
154
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
155
|
+
if (!/^JWT_[A-Z0-9]+(?:_[A-Z0-9]+)*_SECRET$/.test(key)) continue
|
|
156
|
+
enforceSecretPolicy(key, value)
|
|
157
|
+
}
|
|
44
158
|
}
|
|
45
159
|
|
|
46
160
|
function readBaseSecret(explicit?: string): string {
|
|
47
161
|
const secret = explicit ?? process.env.JWT_SECRET
|
|
48
162
|
if (!secret) throw new Error('JWT_SECRET is not set')
|
|
163
|
+
if (explicit === undefined) enforceSecretPolicy('JWT_SECRET', secret)
|
|
49
164
|
return secret
|
|
50
165
|
}
|
|
51
166
|
|
|
@@ -80,7 +195,10 @@ export function deriveJwtAudienceSecret(audience: string, baseSecret?: string):
|
|
|
80
195
|
if (!normalized) throw new Error('Audience is required to derive a JWT secret')
|
|
81
196
|
const overrideName = `JWT_${normalized.toUpperCase()}_SECRET`
|
|
82
197
|
const override = process.env[overrideName]
|
|
83
|
-
if (override && override.trim().length > 0)
|
|
198
|
+
if (override && override.trim().length > 0) {
|
|
199
|
+
enforceSecretPolicy(overrideName, override)
|
|
200
|
+
return override
|
|
201
|
+
}
|
|
84
202
|
const base = readBaseSecret(baseSecret)
|
|
85
203
|
return deriveAudienceSecretFromBase(normalized, base)
|
|
86
204
|
}
|
|
@@ -192,19 +310,43 @@ function verifyWithOptions(token: string, options: { secret: string; audience?:
|
|
|
192
310
|
return payload
|
|
193
311
|
}
|
|
194
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Whether a raw-secret token is still inside the migration window. A token is only ever legacy
|
|
315
|
+
* for a bounded period after it was issued, so `iat` is mandatory: a token that cannot prove its
|
|
316
|
+
* age cannot prove it is inside the window either, and accepting it would make the window
|
|
317
|
+
* unbounded — which is exactly the defect this guard closes.
|
|
318
|
+
*/
|
|
319
|
+
function isWithinLegacyWindow(payload: JwtPayload, graceMinutes: number): boolean {
|
|
320
|
+
const cutoverAt = getLegacyCutoverEpochSeconds()
|
|
321
|
+
const now = Math.floor(Date.now() / 1000)
|
|
322
|
+
if (cutoverAt === null || now >= cutoverAt) return false
|
|
323
|
+
const issuedAt = payload.iat
|
|
324
|
+
if (typeof issuedAt !== 'number' || !Number.isFinite(issuedAt)) return false
|
|
325
|
+
if (issuedAt > now + LEGACY_TOKEN_CLOCK_SKEW_SECONDS) return false
|
|
326
|
+
return now - issuedAt <= graceMinutes * 60
|
|
327
|
+
}
|
|
328
|
+
|
|
195
329
|
export function verifyJwt(token: string, secretOrOptions?: string | VerifyJwtOptions) {
|
|
196
330
|
const options = toVerifyOptions(secretOrOptions)
|
|
197
331
|
const result = verifyWithOptions(token, options)
|
|
198
|
-
if (result)
|
|
332
|
+
if (result) {
|
|
333
|
+
// `_legacyToken` is assigned by this function alone. Strip any same-named claim carried in
|
|
334
|
+
// the token body so a payload can never talk callers (staff session integrity, portal auth)
|
|
335
|
+
// into treating a modern, session-bound token as a sessionless legacy one.
|
|
336
|
+
if (result._legacyToken !== undefined) delete result._legacyToken
|
|
337
|
+
return result
|
|
338
|
+
}
|
|
199
339
|
|
|
200
340
|
// Legacy fallback: when the caller used the default path (no explicit secret) and the new
|
|
201
|
-
// audience-derived verification failed, try verifying with the raw JWT_SECRET. This
|
|
202
|
-
// pre-migration tokens
|
|
203
|
-
|
|
341
|
+
// audience-derived verification failed, try verifying with the raw JWT_SECRET. This keeps
|
|
342
|
+
// pre-migration tokens working across a rolling deployment — but only until the token's own
|
|
343
|
+
// `iat` leaves the configured grace window, or the configured cutover instant passes.
|
|
344
|
+
if (secretOrOptions === undefined) {
|
|
345
|
+
const graceMinutes = getLegacyGraceMinutes()
|
|
204
346
|
const rawSecret = process.env.JWT_SECRET
|
|
205
|
-
if (rawSecret) {
|
|
347
|
+
if (graceMinutes > 0 && rawSecret) {
|
|
206
348
|
const legacyResult = verifyWithOptions(token, { secret: rawSecret })
|
|
207
|
-
if (legacyResult) {
|
|
349
|
+
if (legacyResult && isWithinLegacyWindow(legacyResult, graceMinutes)) {
|
|
208
350
|
legacyResult._legacyToken = true
|
|
209
351
|
return legacyResult
|
|
210
352
|
}
|