@open-mercato/shared 0.6.4-develop.4299.1.af24e08431 → 0.6.4-develop.4310.1.0be8773280
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 +11 -2
- 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/jest.config.cjs +3 -0
- package/package.json +5 -5
- package/src/lib/auth/__tests__/jwt.test.ts +19 -0
- package/src/lib/auth/jwt.ts +14 -2
package/dist/lib/auth/jwt.js
CHANGED
|
@@ -18,6 +18,16 @@ function readBaseSecret(explicit) {
|
|
|
18
18
|
function normalizeAudience(audience) {
|
|
19
19
|
return audience.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
20
20
|
}
|
|
21
|
+
const derivedSecretCache = /* @__PURE__ */ new Map();
|
|
22
|
+
function deriveAudienceSecretFromBase(normalized, base) {
|
|
23
|
+
const cacheKey = `${normalized}::${base}`;
|
|
24
|
+
const cached = derivedSecretCache.get(cacheKey);
|
|
25
|
+
if (cached !== void 0) return cached;
|
|
26
|
+
const label = `${AUDIENCE_SECRET_LABEL}:${normalized}`;
|
|
27
|
+
const derived = crypto.createHmac("sha256", base).update(label).digest("hex");
|
|
28
|
+
derivedSecretCache.set(cacheKey, derived);
|
|
29
|
+
return derived;
|
|
30
|
+
}
|
|
21
31
|
function deriveJwtAudienceSecret(audience, baseSecret) {
|
|
22
32
|
const normalized = normalizeAudience(audience);
|
|
23
33
|
if (!normalized) throw new Error("Audience is required to derive a JWT secret");
|
|
@@ -25,8 +35,7 @@ function deriveJwtAudienceSecret(audience, baseSecret) {
|
|
|
25
35
|
const override = process.env[overrideName];
|
|
26
36
|
if (override && override.trim().length > 0) return override;
|
|
27
37
|
const base = readBaseSecret(baseSecret);
|
|
28
|
-
|
|
29
|
-
return crypto.createHmac("sha256", base).update(label).digest("hex");
|
|
38
|
+
return deriveAudienceSecretFromBase(normalized, base);
|
|
30
39
|
}
|
|
31
40
|
function isSignOptions(value) {
|
|
32
41
|
return typeof value === "object" && value !== null;
|
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\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 */\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
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AAEnB,SAAS,UAAU,OAAwB;AACzC,UAAQ,OAAO,UAAU,WAAW,OAAO,KAAK,KAAK,IAAI,OACtD,SAAS,QAAQ,EACjB,QAAQ,MAAM,EAAE,EAChB,QAAQ,OAAO,GAAG,EAClB,QAAQ,OAAO,GAAG;AACvB;AAmBA,MAAM,iBAAiB;AACvB,MAAM,yBAAsC;AAC5C,MAAM,wBAAwB;AAU9B,SAAS,wBAAiC;AACxC,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,QAAQ,OAAO,QAAQ,WAAW,QAAQ,MAAO,QAAO;AAC5D,SAAO;AACT;AAEA,SAAS,eAAe,UAA2B;AACjD,QAAM,SAAS,YAAY,QAAQ,IAAI;AACvC,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,uBAAuB;AACpD,SAAO;AACT;AAEA,SAAS,kBAAkB,UAA0B;AACnD,SAAO,SAAS,KAAK,EAAE,YAAY,EAAE,QAAQ,eAAe,GAAG;AACjE;
|
|
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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",
|
|
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.4-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.4-develop.4310.1.0be8773280'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/jest.config.cjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.4-develop.
|
|
3
|
+
"version": "0.6.4-develop.4310.1.0be8773280",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
|
-
"@mikro-orm/core": "^7.1.
|
|
93
|
-
"@mikro-orm/decorators": "^7.1.
|
|
94
|
-
"@mikro-orm/postgresql": "^7.1.
|
|
95
|
-
"@open-mercato/cache": "0.6.4-develop.
|
|
92
|
+
"@mikro-orm/core": "^7.1.3",
|
|
93
|
+
"@mikro-orm/decorators": "^7.1.3",
|
|
94
|
+
"@mikro-orm/postgresql": "^7.1.3",
|
|
95
|
+
"@open-mercato/cache": "0.6.4-develop.4310.1.0be8773280",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.1.0",
|
|
98
98
|
"re2js": "2.8.3",
|
|
@@ -116,6 +116,25 @@ describe('jwt helpers', () => {
|
|
|
116
116
|
expect(customerKey).not.toBe(baseSecret)
|
|
117
117
|
})
|
|
118
118
|
|
|
119
|
+
it('memoizes HMAC derivation per audience and base secret within the process', () => {
|
|
120
|
+
const createHmacSpy = jest.spyOn(crypto, 'createHmac')
|
|
121
|
+
const isolatedBase = 'memo-test-base-secret'
|
|
122
|
+
try {
|
|
123
|
+
createHmacSpy.mockClear()
|
|
124
|
+
deriveJwtAudienceSecret('memo_staff_a', isolatedBase)
|
|
125
|
+
deriveJwtAudienceSecret('memo_staff_a', isolatedBase)
|
|
126
|
+
expect(createHmacSpy).toHaveBeenCalledTimes(1)
|
|
127
|
+
|
|
128
|
+
deriveJwtAudienceSecret('memo_customer_a', isolatedBase)
|
|
129
|
+
expect(createHmacSpy).toHaveBeenCalledTimes(2)
|
|
130
|
+
|
|
131
|
+
deriveJwtAudienceSecret('memo_staff_a', 'memo-other-base-secret')
|
|
132
|
+
expect(createHmacSpy).toHaveBeenCalledTimes(3)
|
|
133
|
+
} finally {
|
|
134
|
+
createHmacSpy.mockRestore()
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
119
138
|
it('honors per-audience env overrides ahead of the derived secret', () => {
|
|
120
139
|
process.env.JWT_CUSTOMER_SECRET = 'explicit-customer-secret'
|
|
121
140
|
expect(deriveJwtAudienceSecret('customer')).toBe('explicit-customer-secret')
|
package/src/lib/auth/jwt.ts
CHANGED
|
@@ -53,6 +53,18 @@ function normalizeAudience(audience: string): string {
|
|
|
53
53
|
return audience.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const derivedSecretCache = new Map<string, string>()
|
|
57
|
+
|
|
58
|
+
function deriveAudienceSecretFromBase(normalized: string, base: string): string {
|
|
59
|
+
const cacheKey = `${normalized}::${base}`
|
|
60
|
+
const cached = derivedSecretCache.get(cacheKey)
|
|
61
|
+
if (cached !== undefined) return cached
|
|
62
|
+
const label = `${AUDIENCE_SECRET_LABEL}:${normalized}`
|
|
63
|
+
const derived = crypto.createHmac('sha256', base).update(label).digest('hex')
|
|
64
|
+
derivedSecretCache.set(cacheKey, derived)
|
|
65
|
+
return derived
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
/**
|
|
57
69
|
* Derive a per-audience signing key from the base `JWT_SECRET`.
|
|
58
70
|
*
|
|
@@ -61,6 +73,7 @@ function normalizeAudience(audience: string): string {
|
|
|
61
73
|
* - Otherwise, the key is derived deterministically via HMAC-SHA256 from the base secret using a
|
|
62
74
|
* versioned label. This ensures that a staff JWT signature cannot verify against the customer
|
|
63
75
|
* key (and vice versa) even though both share the same base `JWT_SECRET`.
|
|
76
|
+
* - Derived values are memoized per `(audience, baseSecret)` for the process lifetime.
|
|
64
77
|
*/
|
|
65
78
|
export function deriveJwtAudienceSecret(audience: string, baseSecret?: string): string {
|
|
66
79
|
const normalized = normalizeAudience(audience)
|
|
@@ -69,8 +82,7 @@ export function deriveJwtAudienceSecret(audience: string, baseSecret?: string):
|
|
|
69
82
|
const override = process.env[overrideName]
|
|
70
83
|
if (override && override.trim().length > 0) return override
|
|
71
84
|
const base = readBaseSecret(baseSecret)
|
|
72
|
-
|
|
73
|
-
return crypto.createHmac('sha256', base).update(label).digest('hex')
|
|
85
|
+
return deriveAudienceSecretFromBase(normalized, base)
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
function isSignOptions(value: string | SignJwtOptions | undefined): value is SignJwtOptions {
|