@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.
@@ -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
- function getLegacyGraceEnabled() {
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
- if (raw === "0" || raw === "false" || raw === "off") return false;
11
- return true;
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) return override;
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) return result;
137
- if (secretOrOptions === void 0 && getLegacyGraceEnabled()) {
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,
@@ -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": "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",
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
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.8-develop.6944.1.eef6a0ee1d";
1
+ const APP_VERSION = "0.6.8-develop.6948.1.8369fc4c97";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.6944.1.eef6a0ee1d';\nexport const appVersion = APP_VERSION;\n"],
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.6944.1.eef6a0ee1d",
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.6944.1.eef6a0ee1d",
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
  })
@@ -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
- * When set to a positive number (minutes), `verifyJwt` will attempt a legacy fallback using the
34
- * raw `JWT_SECRET` when the audience-derived verification fails. This supports rolling deployments
35
- * and lets existing sessions expire gracefully instead of force-logging-out every user on deploy.
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` env var. Defaults to 480 (8 hours — one full token TTL).
38
- * Set to 0 to disable the fallback (hard cutover).
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 getLegacyGraceEnabled(): boolean {
48
+ function getLegacyGraceMinutes(): number {
41
49
  const raw = process.env.JWT_LEGACY_GRACE_MINUTES
42
- if (raw === '0' || raw === 'false' || raw === 'off') return false
43
- return true
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) return override
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) return 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 allows
202
- // pre-migration tokens to remain valid during rolling deployments and graceful migration.
203
- if (secretOrOptions === undefined && getLegacyGraceEnabled()) {
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
  }