@open-mercato/shared 0.6.8-develop.7072.1.19c2a8bbe0 → 0.6.8-develop.7076.1.13d0af067b

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,2 +1,2 @@
1
- [build:shared] found 266 entry points
1
+ [build:shared] found 267 entry points
2
2
  [build:shared] built successfully
@@ -247,9 +247,15 @@ function signAudienceJwt(audience, payload, expiresInSec = 60 * 60 * 8) {
247
247
  function verifyAudienceJwt(audience, token) {
248
248
  return verifyJwt(token, { audience });
249
249
  }
250
+ function isMfaPendingJwtPayload(payload) {
251
+ if (!payload || typeof payload !== "object") return false;
252
+ const record = payload;
253
+ return record.mfa_pending === true && record.mfa_verified !== true;
254
+ }
250
255
  export {
251
256
  assertJwtSecretPolicy,
252
257
  deriveJwtAudienceSecret,
258
+ isMfaPendingJwtPayload,
253
259
  signAudienceJwt,
254
260
  signJwt,
255
261
  verifyAudienceJwt,
@@ -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'\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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",
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\n/**\n * True when a verified JWT payload represents an MFA challenge in progress: the second factor\n * has not been completed yet (`mfa_pending: true`, `mfa_verified` not `true`). Such tokens are\n * provisional \u2014 general staff APIs must reject them and only the MFA completion routes may\n * accept them (see `@open-mercato/shared/lib/auth/mfaPendingAccess`).\n *\n * The predicate keys on `mfa_pending` alone by design. `mfa_verified` is NOT a durable session\n * property: routes that legitimately re-sign a staff token (`PUT /api/auth/profile`,\n * `GET|POST /api/auth/session/refresh`) rebuild the claims without it. Tightening this to require\n * `mfa_verified === true` would therefore silently downgrade those sessions.\n */\nexport function isMfaPendingJwtPayload(payload: unknown): boolean {\n if (!payload || typeof payload !== 'object') return false\n const record = payload as Record<string, unknown>\n return record.mfa_pending === true && record.mfa_verified !== true\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,42 @@
1
+ const registeredMethodsByPath = /* @__PURE__ */ new Map();
2
+ function normalizePath(path) {
3
+ if (typeof path !== "string") return null;
4
+ const trimmed = path.trim();
5
+ if (!trimmed || !trimmed.startsWith("/")) return null;
6
+ return (trimmed.length > 1 ? trimmed.replace(/\/+$/, "") : trimmed).toLowerCase() || "/";
7
+ }
8
+ function normalizeMethods(methods) {
9
+ if (!Array.isArray(methods)) return [];
10
+ const normalized = methods.filter((method) => typeof method === "string" && method.trim().length > 0).map((method) => method.trim().toUpperCase());
11
+ return Array.from(new Set(normalized)).sort((first, second) => first < second ? -1 : first > second ? 1 : 0);
12
+ }
13
+ function registerMfaPendingAccessRoutes(routes) {
14
+ for (const route of Array.isArray(routes) ? routes : []) {
15
+ const path = normalizePath(route?.path ?? "");
16
+ const methods = normalizeMethods(route?.methods);
17
+ if (!path || !methods.length) continue;
18
+ const existing = registeredMethodsByPath.get(path);
19
+ if (!existing) {
20
+ registeredMethodsByPath.set(path, new Set(methods));
21
+ continue;
22
+ }
23
+ for (const method of methods) existing.add(method);
24
+ }
25
+ }
26
+ function isMfaPendingAccessAllowed(method, pathname) {
27
+ const path = normalizePath(typeof pathname === "string" ? pathname : "");
28
+ if (!path) return false;
29
+ if (typeof method !== "string" || method.trim().length === 0) return false;
30
+ const methods = registeredMethodsByPath.get(path);
31
+ if (!methods) return false;
32
+ return methods.has(method.trim().toUpperCase());
33
+ }
34
+ function listMfaPendingAccessRoutes() {
35
+ return Array.from(registeredMethodsByPath.entries()).map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => first < second ? -1 : first > second ? 1 : 0) })).sort((first, second) => first.path < second.path ? -1 : first.path > second.path ? 1 : 0);
36
+ }
37
+ export {
38
+ isMfaPendingAccessAllowed,
39
+ listMfaPendingAccessRoutes,
40
+ registerMfaPendingAccessRoutes
41
+ };
42
+ //# sourceMappingURL=mfaPendingAccess.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/auth/mfaPendingAccess.ts"],
4
+ "sourcesContent": ["export type MfaPendingAccessRoute = {\n path: string\n methods: string[]\n}\n\nconst registeredMethodsByPath = new Map<string, Set<string>>()\n\nfunction normalizePath(path: string): string | null {\n if (typeof path !== 'string') return null\n const trimmed = path.trim()\n if (!trimmed || !trimmed.startsWith('/')) return null\n return (trimmed.length > 1 ? trimmed.replace(/\\/+$/, '') : trimmed).toLowerCase() || '/'\n}\n\nfunction normalizeMethods(methods: unknown): string[] {\n if (!Array.isArray(methods)) return []\n const normalized = methods\n .filter((method): method is string => typeof method === 'string' && method.trim().length > 0)\n .map((method) => method.trim().toUpperCase())\n return Array.from(new Set(normalized)).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0))\n}\n\n/**\n * Register routes that may accept an MFA-pending staff token. The registry starts empty and\n * stays generic: every module that completes an MFA challenge on its own endpoints registers\n * them here during its bootstrap \u2014 the canonical `/api/security/mfa/*` completion routes are\n * registered by the enterprise `security` module (`lib/mfaCompletionRoutes.ts`), third-party\n * MFA implementations register theirs the same way. Registration is additive: methods merge\n * into any existing entry for the same path and are never removed.\n *\n * Contract defined in `.ai/specs/enterprise/implemented/SPEC-ENT-007-2026-03-06-auth-login-interceptors-extension.md`\n * (\u00A7 Amendment 2026-08-21 \u2014 central MFA-pending token gate).\n */\nexport function registerMfaPendingAccessRoutes(routes: MfaPendingAccessRoute[]): void {\n for (const route of Array.isArray(routes) ? routes : []) {\n const path = normalizePath(route?.path ?? '')\n const methods = normalizeMethods(route?.methods)\n if (!path || !methods.length) continue\n const existing = registeredMethodsByPath.get(path)\n if (!existing) {\n registeredMethodsByPath.set(path, new Set(methods))\n continue\n }\n for (const method of methods) existing.add(method)\n }\n}\n\n/**\n * Fail-closed check deciding whether an MFA-pending staff token may be resolved for this exact\n * method + path pair. Only explicitly registered completion routes pass; every unregistered\n * request is denied.\n */\nexport function isMfaPendingAccessAllowed(\n method: string | null | undefined,\n pathname: string | null | undefined,\n): boolean {\n const path = normalizePath(typeof pathname === 'string' ? pathname : '')\n if (!path) return false\n if (typeof method !== 'string' || method.trim().length === 0) return false\n const methods = registeredMethodsByPath.get(path)\n if (!methods) return false\n return methods.has(method.trim().toUpperCase())\n}\n\n/** Test/ops helper: current snapshot of the pending-access registry. */\nexport function listMfaPendingAccessRoutes(): ReadonlyArray<Readonly<MfaPendingAccessRoute>> {\n return Array.from(registeredMethodsByPath.entries())\n .map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0)) }))\n .sort((first, second) => (first.path < second.path ? -1 : first.path > second.path ? 1 : 0))\n}\n"],
5
+ "mappings": "AAKA,MAAM,0BAA0B,oBAAI,IAAyB;AAE7D,SAAS,cAAc,MAA6B;AAClD,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,QAAM,UAAU,KAAK,KAAK;AAC1B,MAAI,CAAC,WAAW,CAAC,QAAQ,WAAW,GAAG,EAAG,QAAO;AACjD,UAAQ,QAAQ,SAAS,IAAI,QAAQ,QAAQ,QAAQ,EAAE,IAAI,SAAS,YAAY,KAAK;AACvF;AAEA,SAAS,iBAAiB,SAA4B;AACpD,MAAI,CAAC,MAAM,QAAQ,OAAO,EAAG,QAAO,CAAC;AACrC,QAAM,aAAa,QAChB,OAAO,CAAC,WAA6B,OAAO,WAAW,YAAY,OAAO,KAAK,EAAE,SAAS,CAAC,EAC3F,IAAI,CAAC,WAAW,OAAO,KAAK,EAAE,YAAY,CAAC;AAC9C,SAAO,MAAM,KAAK,IAAI,IAAI,UAAU,CAAC,EAAE,KAAK,CAAC,OAAO,WAAY,QAAQ,SAAS,KAAK,QAAQ,SAAS,IAAI,CAAE;AAC/G;AAaO,SAAS,+BAA+B,QAAuC;AACpF,aAAW,SAAS,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC,GAAG;AACvD,UAAM,OAAO,cAAc,OAAO,QAAQ,EAAE;AAC5C,UAAM,UAAU,iBAAiB,OAAO,OAAO;AAC/C,QAAI,CAAC,QAAQ,CAAC,QAAQ,OAAQ;AAC9B,UAAM,WAAW,wBAAwB,IAAI,IAAI;AACjD,QAAI,CAAC,UAAU;AACb,8BAAwB,IAAI,MAAM,IAAI,IAAI,OAAO,CAAC;AAClD;AAAA,IACF;AACA,eAAW,UAAU,QAAS,UAAS,IAAI,MAAM;AAAA,EACnD;AACF;AAOO,SAAS,0BACd,QACA,UACS;AACT,QAAM,OAAO,cAAc,OAAO,aAAa,WAAW,WAAW,EAAE;AACvE,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,OAAO,WAAW,YAAY,OAAO,KAAK,EAAE,WAAW,EAAG,QAAO;AACrE,QAAM,UAAU,wBAAwB,IAAI,IAAI;AAChD,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,QAAQ,IAAI,OAAO,KAAK,EAAE,YAAY,CAAC;AAChD;AAGO,SAAS,6BAA6E;AAC3F,SAAO,MAAM,KAAK,wBAAwB,QAAQ,CAAC,EAChD,IAAI,CAAC,CAAC,MAAM,OAAO,OAAO,EAAE,MAAM,SAAS,MAAM,KAAK,OAAO,EAAE,KAAK,CAAC,OAAO,WAAY,QAAQ,SAAS,KAAK,QAAQ,SAAS,IAAI,CAAE,EAAE,EAAE,EACzI,KAAK,CAAC,OAAO,WAAY,MAAM,OAAO,OAAO,OAAO,KAAK,MAAM,OAAO,OAAO,OAAO,IAAI,CAAE;AAC/F;",
6
+ "names": []
7
+ }
@@ -1,7 +1,15 @@
1
1
  import { cookies } from "next/headers.js";
2
- import { verifyJwt } from "./jwt.js";
2
+ import { isMfaPendingJwtPayload, verifyJwt } from "./jwt.js";
3
+ import { isMfaPendingAccessAllowed } from "./mfaPendingAccess.js";
3
4
  import { getSharedApiKeyAuthCache } from "./apiKeyAuthCache.js";
4
5
  import { isTransientDbError } from "@open-mercato/shared/lib/db/pg-errors";
6
+ function readRequestPathname(req) {
7
+ try {
8
+ return new URL(req.url).pathname;
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
5
13
  const TENANT_COOKIE_NAME = "om_selected_tenant";
6
14
  const ORGANIZATION_COOKIE_NAME = "om_selected_org";
7
15
  const ALL_ORGANIZATIONS_COOKIE_VALUE = "__all__";
@@ -232,6 +240,7 @@ async function resolveAuthFromCookiesDetailed() {
232
240
  const payload = verifyJwt(token);
233
241
  if (!payload) return { auth: null, status: "invalid" };
234
242
  if (payload.type === "customer") return { auth: null, status: "invalid" };
243
+ if (isMfaPendingJwtPayload(payload)) return { auth: null, status: "invalid" };
235
244
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload);
236
245
  if (!canonicalAuth) return { auth: null, status: "invalid" };
237
246
  const tenantCookie = cookieStore.get(TENANT_COOKIE_NAME)?.value;
@@ -272,6 +281,11 @@ async function resolveAuthFromRequestDetailed(req) {
272
281
  try {
273
282
  const payload = verifyJwt(token);
274
283
  if (payload && payload.type === "customer") return { auth: null, status: "invalid" };
284
+ if (payload && isMfaPendingJwtPayload(payload)) {
285
+ if (!isMfaPendingAccessAllowed(req.method, readRequestPathname(req))) {
286
+ return { auth: null, status: "invalid" };
287
+ }
288
+ }
275
289
  if (payload) {
276
290
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload);
277
291
  if (canonicalAuth) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/auth/server.ts"],
4
- "sourcesContent": ["import { cookies } from 'next/headers.js'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { verifyJwt } from './jwt'\nimport { getSharedApiKeyAuthCache } from './apiKeyAuthCache'\nimport { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'\n\nconst TENANT_COOKIE_NAME = 'om_selected_tenant'\nconst ORGANIZATION_COOKIE_NAME = 'om_selected_org'\nconst ALL_ORGANIZATIONS_COOKIE_VALUE = '__all__'\nconst SUPERADMIN_ROLE = 'superadmin'\n\nexport type AuthContext = {\n sub: string\n sid?: string | null\n tenantId: string | null\n orgId: string | null\n email?: string\n roles?: string[]\n isApiKey?: boolean\n userId?: string\n keyId?: string\n keyName?: string\n [k: string]: unknown\n} | null\n\ntype CookieOverride = { applied: boolean; value: string | null }\n// 'error' means auth could not be determined because of a transient/unexpected\n// failure (DB unavailable, connection-pool exhaustion, statement timeout, \u2026) \u2014\n// NOT because the token is genuinely invalid. Callers MUST NOT clear the user's\n// session cookies for 'error'; doing so would log everyone out at once during a\n// shared infrastructure blip (issue #4176).\ntype AuthResolutionStatus = 'authenticated' | 'missing' | 'invalid' | 'error'\ntype AuthResolution = {\n auth: AuthContext\n status: AuthResolutionStatus\n}\n\n// Thrown by `resolveCanonicalInteractiveAuthContext` when the canonical auth\n// lookup itself fails unexpectedly (as opposed to resolving to a genuine\n// \"not authorized\" null). Kept distinct so resolvers can map it to the\n// non-destructive 'error' status instead of the cookie-clearing 'invalid'.\nexport class AuthResolutionUnavailableError extends Error {\n constructor(cause?: unknown) {\n super('Auth resolution unavailable')\n this.name = 'AuthResolutionUnavailableError'\n if (cause !== undefined) (this as { cause?: unknown }).cause = cause\n }\n}\n\n// Symbol-keyed trusted auth context. Set on synthetic Request objects by\n// callers that have already authenticated (e.g. the AI in-process operation\n// runner) so downstream auth resolution short-circuits without re-running\n// cookie/JWT/API-key parsing. The hook is fail-open: if absent the normal\n// resolution path runs unchanged.\nexport const TRUSTED_AUTH_CONTEXT_SYMBOL = Symbol.for('open-mercato.auth.trustedContext')\n\nexport type TrustedAuthContextEnvelope = {\n auth: AuthContext\n status?: AuthResolutionStatus\n}\n\nexport function attachTrustedAuthContext(\n request: Request,\n envelope: TrustedAuthContextEnvelope\n): Request {\n ;(request as unknown as Record<symbol, TrustedAuthContextEnvelope>)[TRUSTED_AUTH_CONTEXT_SYMBOL] = envelope\n return request\n}\n\nfunction readTrustedAuthContext(request: Request): TrustedAuthContextEnvelope | null {\n const carrier = request as unknown as Record<symbol, unknown>\n const envelope = carrier[TRUSTED_AUTH_CONTEXT_SYMBOL]\n if (!envelope || typeof envelope !== 'object') return null\n const candidate = envelope as TrustedAuthContextEnvelope\n if (!('auth' in candidate)) return null\n return candidate\n}\n\nfunction decodeCookieValue(raw: string | undefined): string | null {\n if (raw === undefined) return null\n try {\n const decoded = decodeURIComponent(raw)\n return decoded ?? null\n } catch {\n return raw ?? null\n }\n}\n\nfunction readCookieFromHeader(header: string | null | undefined, name: string): string | undefined {\n if (!header) return undefined\n const parts = header.split(';')\n for (const part of parts) {\n const trimmed = part.trim()\n if (trimmed.startsWith(`${name}=`)) {\n return trimmed.slice(name.length + 1)\n }\n }\n return undefined\n}\n\n/**\n * A blank `om_selected_tenant` is \"no selection\", not a deliberate \"no tenant\".\n *\n * Unlike the organization cookie there is no all-tenants sentinel to express, and the tenant\n * selector renders with `includeEmptyOption={false}`, so no UI produces a blank value. Treating it\n * as an applied override nulled `tenantId` for the whole super-admin session, which is what turned\n * a cosmetic client bug into `tenantId ?? ''` reaching a NOT NULL uuid column. Reporting the\n * override as not applied instead keeps the tenant carried in the token and remediates an\n * already-poisoned browser on its next request, whatever wrote the cookie.\n */\nfunction resolveTenantOverride(raw: string | undefined): CookieOverride {\n if (raw === undefined) return { applied: false, value: null }\n const decoded = decodeCookieValue(raw)\n if (!decoded) return { applied: false, value: null }\n const trimmed = decoded.trim()\n if (!trimmed) return { applied: false, value: null }\n return { applied: true, value: trimmed }\n}\n\nfunction resolveOrganizationOverride(raw: string | undefined): CookieOverride {\n if (raw === undefined) return { applied: false, value: null }\n const decoded = decodeCookieValue(raw)\n if (!decoded || decoded === ALL_ORGANIZATIONS_COOKIE_VALUE) {\n return { applied: true, value: null }\n }\n const trimmed = decoded.trim()\n if (!trimmed || trimmed === ALL_ORGANIZATIONS_COOKIE_VALUE) {\n return { applied: true, value: null }\n }\n return { applied: true, value: trimmed }\n}\n\nfunction isSuperAdminAuth(auth: AuthContext | null | undefined): boolean {\n if (!auth) return false\n return (auth as Record<string, unknown>).isSuperAdmin === true\n}\n\nfunction applySuperAdminScope(\n auth: AuthContext,\n tenantCookie: string | undefined,\n orgCookie: string | undefined\n): AuthContext {\n if (!auth || !isSuperAdminAuth(auth)) return auth\n\n const tenantOverride = resolveTenantOverride(tenantCookie)\n const orgOverride = resolveOrganizationOverride(orgCookie)\n if (!tenantOverride.applied && !orgOverride.applied) return auth\n\n type MutableAuthContext = Exclude<AuthContext, null> & {\n actorTenantId?: string | null\n actorOrgId?: string | null\n }\n const baseAuth = auth as Exclude<AuthContext, null>\n const next: MutableAuthContext = { ...baseAuth }\n if (tenantOverride.applied) {\n if (!('actorTenantId' in next)) next.actorTenantId = auth?.tenantId ?? null\n next.tenantId = tenantOverride.value\n }\n if (orgOverride.applied) {\n if (!('actorOrgId' in next)) next.actorOrgId = auth?.orgId ?? null\n next.orgId = orgOverride.value\n }\n next.isSuperAdmin = true\n const existingRoles = Array.isArray(next.roles) ? next.roles : []\n if (!existingRoles.some((role) => typeof role === 'string' && role.trim().toLowerCase() === SUPERADMIN_ROLE)) {\n next.roles = [...existingRoles, 'superadmin']\n }\n return next\n}\n\nasync function resolveApiKeyAuth(secret: string): Promise<AuthContext> {\n if (!secret) return null\n const cache = getSharedApiKeyAuthCache()\n const cached = cache.get(secret)\n if (cached !== undefined) return cached as AuthContext\n try {\n const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n const { findApiKeyBySecret } = await import('@open-mercato/core/modules/api_keys/services/apiKeyService')\n const { Role, RoleAcl, User } = await import('@open-mercato/core/modules/auth/data/entities')\n const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')\n\n const record = await findApiKeyBySecret(em, secret)\n if (!record) {\n cache.setMiss(secret)\n return null\n }\n\n const roleIds = Array.isArray(record.rolesJson)\n ? record.rolesJson.filter((value): value is string => typeof value === 'string' && value.length > 0)\n : []\n const roles = roleIds.length\n ? await em.find(Role, { id: { $in: roleIds }, deletedAt: null })\n : []\n const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)\n\n // A role-level super-admin grant is authorization-grade only when it is genuinely\n // unrestricted: the grant itself must not be organization-scoped, it must belong to the\n // key's tenant, and the key must not be bound to a single organization. RbacService applies\n // the same intersection when projecting the scoped ACL, but generic guards\n // (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*\n // live RBAC runs \u2014 so an organization-restricted key must never carry it.\n let keyIsSuperAdmin = false\n const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0\n ? record.organizationId.trim()\n : null\n if (roleIds.length && !keyOrganizationId) {\n const superAcls = await em.find(\n RoleAcl,\n {\n role: { $in: roleIds } as any,\n tenantId: record.tenantId ?? null,\n isSuperAdmin: true,\n deletedAt: null,\n } as any,\n )\n keyIsSuperAdmin = superAcls.some((acl) => {\n const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)\n ? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]\n : null\n // An empty list means \"inherit the key's own binding\"; with no binding that is tenant-wide.\n return !organizations || organizations.length === 0 || organizations.includes('__all__')\n })\n }\n\n if (cache.shouldWriteLastUsed(record.id)) {\n try {\n record.lastUsedAt = new Date()\n await em.persist(record).flush()\n } catch {\n // best-effort update; ignore write failures\n }\n }\n\n // Ephemeral session keys are always user-bound. Regular keys retain their\n // legacy creator identity, while tenant-scoped regular keys ignore only the\n // creator's concrete organization when validating the wider key scope.\n // Keep every session marker fail-closed so a malformed session key cannot\n // fall back to the regular key path and escape its user/scope binding.\n const isSessionBoundKey = Boolean(\n record.sessionToken\n || record.sessionUserId\n || record.sessionSecretEncrypted\n || record.opencodeSessionId\n )\n const actualUserId = isSessionBoundKey\n ? record.sessionUserId ?? null\n : record.createdBy ?? null\n\n if (isSessionBoundKey && !actualUserId) {\n cache.setMiss(secret)\n return null\n }\n\n if (actualUserId) {\n const user = await em.findOne(User, { id: actualUserId, deletedAt: null })\n if (!user) {\n cache.setMiss(secret)\n return null\n }\n if ((user.tenantId ?? null) !== (record.tenantId ?? null)) {\n cache.setMiss(secret)\n return null\n }\n const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)\n if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {\n cache.setMiss(secret)\n return null\n }\n } else {\n if (record.tenantId) {\n const tenant = await em.findOne(Tenant, { id: record.tenantId, deletedAt: null, isActive: true })\n if (!tenant) {\n cache.setMiss(secret)\n return null\n }\n }\n if (record.organizationId) {\n const organization = await em.findOne(Organization, { id: record.organizationId, deletedAt: null, isActive: true })\n if (!organization) {\n cache.setMiss(secret)\n return null\n }\n if (record.tenantId && String(organization.tenant.id) !== record.tenantId) {\n cache.setMiss(secret)\n return null\n }\n }\n }\n\n const auth: Exclude<AuthContext, null> = {\n sub: `api_key:${record.id}`,\n tenantId: record.tenantId ?? null,\n orgId: record.organizationId ?? null,\n roles: roleNames,\n isApiKey: true,\n isSuperAdmin: keyIsSuperAdmin,\n keyId: record.id,\n keyName: record.name,\n ...(actualUserId ? { userId: actualUserId } : {}),\n }\n cache.setSuccess(secret, auth, record.expiresAt ? record.expiresAt.getTime() : null)\n return auth\n } catch (err) {\n // A transient DB failure (pool exhausted, `max_connections` reached, DB\n // restarting) means we could not confirm the key \u2014 NOT that it is invalid.\n // Surface it so callers return a retryable 503 instead of masking it as an\n // auth miss (401). Genuine misses already returned `null` above.\n if (isTransientDbError(err)) {\n throw new AuthResolutionUnavailableError(err)\n }\n return null\n }\n}\n\nfunction extractApiKey(req: Request): string | null {\n const header = (req.headers.get('x-api-key') || '').trim()\n if (header) return header\n const authHeader = (req.headers.get('authorization') || '').trim()\n if (authHeader.toLowerCase().startsWith('apikey ')) {\n return authHeader.slice(7).trim()\n }\n return null\n}\n\nasync function resolveCanonicalInteractiveAuthContext(auth: AuthContext): Promise<AuthContext> {\n if (!auth || auth.isApiKey) return auth\n if (typeof auth.sub !== 'string' || auth.sub.trim().length === 0) return null\n\n try {\n const [{ createRequestContainer }, { resolveCanonicalStaffAuthContext }] = await Promise.all([\n import('@open-mercato/shared/lib/di/container'),\n import('@open-mercato/core/modules/auth/lib/sessionIntegrity'),\n ])\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n return await resolveCanonicalStaffAuthContext(em, auth)\n } catch (err) {\n // A thrown error here means we could not check the token against the\n // database (DB down, pool exhausted, timeout). This is NOT the same as the\n // token being invalid \u2014 surface it so callers keep the session intact and\n // fail the request transiently instead of force-logging the user out.\n throw new AuthResolutionUnavailableError(err)\n }\n}\n\nexport async function resolveAuthFromCookiesDetailed(): Promise<AuthResolution> {\n const cookieStore = await cookies()\n const token = cookieStore.get('auth_token')?.value\n if (!token) return { auth: null, status: 'missing' }\n try {\n const payload = verifyJwt(token) as AuthContext\n if (!payload) return { auth: null, status: 'invalid' }\n if (payload.type === 'customer') return { auth: null, status: 'invalid' }\n const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)\n if (!canonicalAuth) return { auth: null, status: 'invalid' }\n const tenantCookie = cookieStore.get(TENANT_COOKIE_NAME)?.value\n const orgCookie = cookieStore.get(ORGANIZATION_COOKIE_NAME)?.value\n return {\n auth: applySuperAdminScope(canonicalAuth, tenantCookie, orgCookie),\n status: 'authenticated',\n }\n } catch (err) {\n if (err instanceof AuthResolutionUnavailableError) return { auth: null, status: 'error' }\n return { auth: null, status: 'invalid' }\n }\n}\n\nexport async function getAuthFromCookies(): Promise<AuthContext> {\n return (await resolveAuthFromCookiesDetailed()).auth\n}\n\nexport async function resolveAuthFromRequestDetailed(req: Request): Promise<AuthResolution> {\n const trusted = readTrustedAuthContext(req)\n if (trusted) {\n return {\n auth: trusted.auth,\n status: trusted.status ?? (trusted.auth ? 'authenticated' : 'missing'),\n }\n }\n const cookieHeader = req.headers.get('cookie') || ''\n const tenantCookie = readCookieFromHeader(cookieHeader, TENANT_COOKIE_NAME)\n const orgCookie = readCookieFromHeader(cookieHeader, ORGANIZATION_COOKIE_NAME)\n const authHeader = (req.headers.get('authorization') || '').trim()\n let token: string | undefined\n let hadInvalidInteractiveToken = false\n let hadUnavailableResolution = false\n if (authHeader.toLowerCase().startsWith('bearer ')) token = authHeader.slice(7).trim()\n if (!token) {\n const match = cookieHeader.match(/(?:^|;\\s*)auth_token=([^;]+)/)\n if (match) token = decodeURIComponent(match[1])\n }\n if (token) {\n try {\n const payload = verifyJwt(token) as AuthContext\n if (payload && payload.type === 'customer') return { auth: null, status: 'invalid' }\n if (payload) {\n const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)\n if (canonicalAuth) {\n return {\n auth: applySuperAdminScope(canonicalAuth, tenantCookie, orgCookie),\n status: 'authenticated',\n }\n }\n hadInvalidInteractiveToken = true\n }\n } catch (err) {\n // Transient/unexpected canonical-resolution failures must not invalidate\n // the token \u2014 surface them so we return 'error' (retryable) rather than\n // clearing the session cookies.\n if (err instanceof AuthResolutionUnavailableError) hadUnavailableResolution = true\n else hadInvalidInteractiveToken = true\n }\n }\n\n const resolveUnauthenticatedStatus = (): AuthResolutionStatus =>\n hadUnavailableResolution ? 'error' : hadInvalidInteractiveToken ? 'invalid' : 'missing'\n\n const apiKey = extractApiKey(req)\n if (!apiKey) {\n return { auth: null, status: resolveUnauthenticatedStatus() }\n }\n let apiAuth: AuthContext\n try {\n apiAuth = await resolveApiKeyAuth(apiKey)\n } catch (err) {\n // Only a transient canonical-resolution failure maps to 'error' (retryable\n // 503), mirroring the interactive-token path above. Anything else is an\n // unexpected bug \u2014 rethrow so it surfaces as a 500 rather than being masked\n // as an auth failure.\n if (!(err instanceof AuthResolutionUnavailableError)) {\n throw err\n }\n hadUnavailableResolution = true\n return { auth: null, status: resolveUnauthenticatedStatus() }\n }\n if (!apiAuth) {\n return { auth: null, status: resolveUnauthenticatedStatus() }\n }\n return {\n auth: applySuperAdminScope(apiAuth, tenantCookie, orgCookie),\n status: 'authenticated',\n }\n}\n\nexport async function getAuthFromRequest(req: Request): Promise<AuthContext> {\n return (await resolveAuthFromRequestDetailed(req)).auth\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { cookies } from 'next/headers.js'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { isMfaPendingJwtPayload, verifyJwt } from './jwt'\nimport { isMfaPendingAccessAllowed } from './mfaPendingAccess'\nimport { getSharedApiKeyAuthCache } from './apiKeyAuthCache'\nimport { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'\n\nfunction readRequestPathname(req: Request): string | null {\n try {\n return new URL(req.url).pathname\n } catch {\n return null\n }\n}\n\nconst TENANT_COOKIE_NAME = 'om_selected_tenant'\nconst ORGANIZATION_COOKIE_NAME = 'om_selected_org'\nconst ALL_ORGANIZATIONS_COOKIE_VALUE = '__all__'\nconst SUPERADMIN_ROLE = 'superadmin'\n\nexport type AuthContext = {\n sub: string\n sid?: string | null\n tenantId: string | null\n orgId: string | null\n email?: string\n roles?: string[]\n isApiKey?: boolean\n userId?: string\n keyId?: string\n keyName?: string\n [k: string]: unknown\n} | null\n\ntype CookieOverride = { applied: boolean; value: string | null }\n// 'error' means auth could not be determined because of a transient/unexpected\n// failure (DB unavailable, connection-pool exhaustion, statement timeout, \u2026) \u2014\n// NOT because the token is genuinely invalid. Callers MUST NOT clear the user's\n// session cookies for 'error'; doing so would log everyone out at once during a\n// shared infrastructure blip (issue #4176).\ntype AuthResolutionStatus = 'authenticated' | 'missing' | 'invalid' | 'error'\ntype AuthResolution = {\n auth: AuthContext\n status: AuthResolutionStatus\n}\n\n// Thrown by `resolveCanonicalInteractiveAuthContext` when the canonical auth\n// lookup itself fails unexpectedly (as opposed to resolving to a genuine\n// \"not authorized\" null). Kept distinct so resolvers can map it to the\n// non-destructive 'error' status instead of the cookie-clearing 'invalid'.\nexport class AuthResolutionUnavailableError extends Error {\n constructor(cause?: unknown) {\n super('Auth resolution unavailable')\n this.name = 'AuthResolutionUnavailableError'\n if (cause !== undefined) (this as { cause?: unknown }).cause = cause\n }\n}\n\n// Symbol-keyed trusted auth context. Set on synthetic Request objects by\n// callers that have already authenticated (e.g. the AI in-process operation\n// runner) so downstream auth resolution short-circuits without re-running\n// cookie/JWT/API-key parsing. The hook is fail-open: if absent the normal\n// resolution path runs unchanged.\nexport const TRUSTED_AUTH_CONTEXT_SYMBOL = Symbol.for('open-mercato.auth.trustedContext')\n\nexport type TrustedAuthContextEnvelope = {\n auth: AuthContext\n status?: AuthResolutionStatus\n}\n\n/**\n * Attach an already-resolved auth context to a synthetic request.\n *\n * INVARIANT callers MUST uphold: `envelope.auth` has to be the product of a gated resolution \u2014\n * `getAuthFromRequest` / `getAuthFromCookies` (or an equivalently validated principal such as an\n * API-key record). The envelope short-circuits `resolveAuthFromRequestDetailed` ahead of both the\n * MFA-pending gate and `resolveCanonicalStaffAuthContext`, so an envelope built straight from an\n * unvalidated JWT would reinstate the MFA bypass this module closes and skip session-revocation\n * checks as well. Never populate it from raw request credentials.\n */\nexport function attachTrustedAuthContext(\n request: Request,\n envelope: TrustedAuthContextEnvelope\n): Request {\n ;(request as unknown as Record<symbol, TrustedAuthContextEnvelope>)[TRUSTED_AUTH_CONTEXT_SYMBOL] = envelope\n return request\n}\n\nfunction readTrustedAuthContext(request: Request): TrustedAuthContextEnvelope | null {\n const carrier = request as unknown as Record<symbol, unknown>\n const envelope = carrier[TRUSTED_AUTH_CONTEXT_SYMBOL]\n if (!envelope || typeof envelope !== 'object') return null\n const candidate = envelope as TrustedAuthContextEnvelope\n if (!('auth' in candidate)) return null\n return candidate\n}\n\nfunction decodeCookieValue(raw: string | undefined): string | null {\n if (raw === undefined) return null\n try {\n const decoded = decodeURIComponent(raw)\n return decoded ?? null\n } catch {\n return raw ?? null\n }\n}\n\nfunction readCookieFromHeader(header: string | null | undefined, name: string): string | undefined {\n if (!header) return undefined\n const parts = header.split(';')\n for (const part of parts) {\n const trimmed = part.trim()\n if (trimmed.startsWith(`${name}=`)) {\n return trimmed.slice(name.length + 1)\n }\n }\n return undefined\n}\n\n/**\n * A blank `om_selected_tenant` is \"no selection\", not a deliberate \"no tenant\".\n *\n * Unlike the organization cookie there is no all-tenants sentinel to express, and the tenant\n * selector renders with `includeEmptyOption={false}`, so no UI produces a blank value. Treating it\n * as an applied override nulled `tenantId` for the whole super-admin session, which is what turned\n * a cosmetic client bug into `tenantId ?? ''` reaching a NOT NULL uuid column. Reporting the\n * override as not applied instead keeps the tenant carried in the token and remediates an\n * already-poisoned browser on its next request, whatever wrote the cookie.\n */\nfunction resolveTenantOverride(raw: string | undefined): CookieOverride {\n if (raw === undefined) return { applied: false, value: null }\n const decoded = decodeCookieValue(raw)\n if (!decoded) return { applied: false, value: null }\n const trimmed = decoded.trim()\n if (!trimmed) return { applied: false, value: null }\n return { applied: true, value: trimmed }\n}\n\nfunction resolveOrganizationOverride(raw: string | undefined): CookieOverride {\n if (raw === undefined) return { applied: false, value: null }\n const decoded = decodeCookieValue(raw)\n if (!decoded || decoded === ALL_ORGANIZATIONS_COOKIE_VALUE) {\n return { applied: true, value: null }\n }\n const trimmed = decoded.trim()\n if (!trimmed || trimmed === ALL_ORGANIZATIONS_COOKIE_VALUE) {\n return { applied: true, value: null }\n }\n return { applied: true, value: trimmed }\n}\n\nfunction isSuperAdminAuth(auth: AuthContext | null | undefined): boolean {\n if (!auth) return false\n return (auth as Record<string, unknown>).isSuperAdmin === true\n}\n\nfunction applySuperAdminScope(\n auth: AuthContext,\n tenantCookie: string | undefined,\n orgCookie: string | undefined\n): AuthContext {\n if (!auth || !isSuperAdminAuth(auth)) return auth\n\n const tenantOverride = resolveTenantOverride(tenantCookie)\n const orgOverride = resolveOrganizationOverride(orgCookie)\n if (!tenantOverride.applied && !orgOverride.applied) return auth\n\n type MutableAuthContext = Exclude<AuthContext, null> & {\n actorTenantId?: string | null\n actorOrgId?: string | null\n }\n const baseAuth = auth as Exclude<AuthContext, null>\n const next: MutableAuthContext = { ...baseAuth }\n if (tenantOverride.applied) {\n if (!('actorTenantId' in next)) next.actorTenantId = auth?.tenantId ?? null\n next.tenantId = tenantOverride.value\n }\n if (orgOverride.applied) {\n if (!('actorOrgId' in next)) next.actorOrgId = auth?.orgId ?? null\n next.orgId = orgOverride.value\n }\n next.isSuperAdmin = true\n const existingRoles = Array.isArray(next.roles) ? next.roles : []\n if (!existingRoles.some((role) => typeof role === 'string' && role.trim().toLowerCase() === SUPERADMIN_ROLE)) {\n next.roles = [...existingRoles, 'superadmin']\n }\n return next\n}\n\nasync function resolveApiKeyAuth(secret: string): Promise<AuthContext> {\n if (!secret) return null\n const cache = getSharedApiKeyAuthCache()\n const cached = cache.get(secret)\n if (cached !== undefined) return cached as AuthContext\n try {\n const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n const { findApiKeyBySecret } = await import('@open-mercato/core/modules/api_keys/services/apiKeyService')\n const { Role, RoleAcl, User } = await import('@open-mercato/core/modules/auth/data/entities')\n const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')\n\n const record = await findApiKeyBySecret(em, secret)\n if (!record) {\n cache.setMiss(secret)\n return null\n }\n\n const roleIds = Array.isArray(record.rolesJson)\n ? record.rolesJson.filter((value): value is string => typeof value === 'string' && value.length > 0)\n : []\n const roles = roleIds.length\n ? await em.find(Role, { id: { $in: roleIds }, deletedAt: null })\n : []\n const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)\n\n // A role-level super-admin grant is authorization-grade only when it is genuinely\n // unrestricted: the grant itself must not be organization-scoped, it must belong to the\n // key's tenant, and the key must not be bound to a single organization. RbacService applies\n // the same intersection when projecting the scoped ACL, but generic guards\n // (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*\n // live RBAC runs \u2014 so an organization-restricted key must never carry it.\n let keyIsSuperAdmin = false\n const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0\n ? record.organizationId.trim()\n : null\n if (roleIds.length && !keyOrganizationId) {\n const superAcls = await em.find(\n RoleAcl,\n {\n role: { $in: roleIds } as any,\n tenantId: record.tenantId ?? null,\n isSuperAdmin: true,\n deletedAt: null,\n } as any,\n )\n keyIsSuperAdmin = superAcls.some((acl) => {\n const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)\n ? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]\n : null\n // An empty list means \"inherit the key's own binding\"; with no binding that is tenant-wide.\n return !organizations || organizations.length === 0 || organizations.includes('__all__')\n })\n }\n\n if (cache.shouldWriteLastUsed(record.id)) {\n try {\n record.lastUsedAt = new Date()\n await em.persist(record).flush()\n } catch {\n // best-effort update; ignore write failures\n }\n }\n\n // Ephemeral session keys are always user-bound. Regular keys retain their\n // legacy creator identity, while tenant-scoped regular keys ignore only the\n // creator's concrete organization when validating the wider key scope.\n // Keep every session marker fail-closed so a malformed session key cannot\n // fall back to the regular key path and escape its user/scope binding.\n const isSessionBoundKey = Boolean(\n record.sessionToken\n || record.sessionUserId\n || record.sessionSecretEncrypted\n || record.opencodeSessionId\n )\n const actualUserId = isSessionBoundKey\n ? record.sessionUserId ?? null\n : record.createdBy ?? null\n\n if (isSessionBoundKey && !actualUserId) {\n cache.setMiss(secret)\n return null\n }\n\n if (actualUserId) {\n const user = await em.findOne(User, { id: actualUserId, deletedAt: null })\n if (!user) {\n cache.setMiss(secret)\n return null\n }\n if ((user.tenantId ?? null) !== (record.tenantId ?? null)) {\n cache.setMiss(secret)\n return null\n }\n const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)\n if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {\n cache.setMiss(secret)\n return null\n }\n } else {\n if (record.tenantId) {\n const tenant = await em.findOne(Tenant, { id: record.tenantId, deletedAt: null, isActive: true })\n if (!tenant) {\n cache.setMiss(secret)\n return null\n }\n }\n if (record.organizationId) {\n const organization = await em.findOne(Organization, { id: record.organizationId, deletedAt: null, isActive: true })\n if (!organization) {\n cache.setMiss(secret)\n return null\n }\n if (record.tenantId && String(organization.tenant.id) !== record.tenantId) {\n cache.setMiss(secret)\n return null\n }\n }\n }\n\n const auth: Exclude<AuthContext, null> = {\n sub: `api_key:${record.id}`,\n tenantId: record.tenantId ?? null,\n orgId: record.organizationId ?? null,\n roles: roleNames,\n isApiKey: true,\n isSuperAdmin: keyIsSuperAdmin,\n keyId: record.id,\n keyName: record.name,\n ...(actualUserId ? { userId: actualUserId } : {}),\n }\n cache.setSuccess(secret, auth, record.expiresAt ? record.expiresAt.getTime() : null)\n return auth\n } catch (err) {\n // A transient DB failure (pool exhausted, `max_connections` reached, DB\n // restarting) means we could not confirm the key \u2014 NOT that it is invalid.\n // Surface it so callers return a retryable 503 instead of masking it as an\n // auth miss (401). Genuine misses already returned `null` above.\n if (isTransientDbError(err)) {\n throw new AuthResolutionUnavailableError(err)\n }\n return null\n }\n}\n\nfunction extractApiKey(req: Request): string | null {\n const header = (req.headers.get('x-api-key') || '').trim()\n if (header) return header\n const authHeader = (req.headers.get('authorization') || '').trim()\n if (authHeader.toLowerCase().startsWith('apikey ')) {\n return authHeader.slice(7).trim()\n }\n return null\n}\n\nasync function resolveCanonicalInteractiveAuthContext(auth: AuthContext): Promise<AuthContext> {\n if (!auth || auth.isApiKey) return auth\n if (typeof auth.sub !== 'string' || auth.sub.trim().length === 0) return null\n\n try {\n const [{ createRequestContainer }, { resolveCanonicalStaffAuthContext }] = await Promise.all([\n import('@open-mercato/shared/lib/di/container'),\n import('@open-mercato/core/modules/auth/lib/sessionIntegrity'),\n ])\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n return await resolveCanonicalStaffAuthContext(em, auth)\n } catch (err) {\n // A thrown error here means we could not check the token against the\n // database (DB down, pool exhausted, timeout). This is NOT the same as the\n // token being invalid \u2014 surface it so callers keep the session intact and\n // fail the request transiently instead of force-logging the user out.\n throw new AuthResolutionUnavailableError(err)\n }\n}\n\nexport async function resolveAuthFromCookiesDetailed(): Promise<AuthResolution> {\n const cookieStore = await cookies()\n const token = cookieStore.get('auth_token')?.value\n if (!token) return { auth: null, status: 'missing' }\n try {\n const payload = verifyJwt(token) as AuthContext\n if (!payload) return { auth: null, status: 'invalid' }\n if (payload.type === 'customer') return { auth: null, status: 'invalid' }\n // MFA-pending tokens are provisional: pages never complete the second factor, so they\n // must not resolve to an authenticated context anywhere in the RSC/page flow.\n if (isMfaPendingJwtPayload(payload)) return { auth: null, status: 'invalid' }\n const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)\n if (!canonicalAuth) return { auth: null, status: 'invalid' }\n const tenantCookie = cookieStore.get(TENANT_COOKIE_NAME)?.value\n const orgCookie = cookieStore.get(ORGANIZATION_COOKIE_NAME)?.value\n return {\n auth: applySuperAdminScope(canonicalAuth, tenantCookie, orgCookie),\n status: 'authenticated',\n }\n } catch (err) {\n if (err instanceof AuthResolutionUnavailableError) return { auth: null, status: 'error' }\n return { auth: null, status: 'invalid' }\n }\n}\n\nexport async function getAuthFromCookies(): Promise<AuthContext> {\n return (await resolveAuthFromCookiesDetailed()).auth\n}\n\nexport async function resolveAuthFromRequestDetailed(req: Request): Promise<AuthResolution> {\n // Deliberately ahead of the MFA-pending gate below: the envelope carries a context that was\n // already gated by its producer (see `attachTrustedAuthContext`), not raw request credentials.\n const trusted = readTrustedAuthContext(req)\n if (trusted) {\n return {\n auth: trusted.auth,\n status: trusted.status ?? (trusted.auth ? 'authenticated' : 'missing'),\n }\n }\n const cookieHeader = req.headers.get('cookie') || ''\n const tenantCookie = readCookieFromHeader(cookieHeader, TENANT_COOKIE_NAME)\n const orgCookie = readCookieFromHeader(cookieHeader, ORGANIZATION_COOKIE_NAME)\n const authHeader = (req.headers.get('authorization') || '').trim()\n let token: string | undefined\n let hadInvalidInteractiveToken = false\n let hadUnavailableResolution = false\n if (authHeader.toLowerCase().startsWith('bearer ')) token = authHeader.slice(7).trim()\n if (!token) {\n const match = cookieHeader.match(/(?:^|;\\s*)auth_token=([^;]+)/)\n if (match) token = decodeURIComponent(match[1])\n }\n if (token) {\n try {\n const payload = verifyJwt(token) as AuthContext\n if (payload && payload.type === 'customer') return { auth: null, status: 'invalid' }\n if (payload && isMfaPendingJwtPayload(payload)) {\n // MFA-pending tokens authenticate only the registered challenge-completion routes;\n // everywhere else they resolve to `invalid` so the dispatcher answers 401 (clearing\n // staff auth cookies) instead of restoring the account's roles/features.\n if (!isMfaPendingAccessAllowed(req.method, readRequestPathname(req))) {\n return { auth: null, status: 'invalid' }\n }\n }\n if (payload) {\n const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)\n if (canonicalAuth) {\n return {\n auth: applySuperAdminScope(canonicalAuth, tenantCookie, orgCookie),\n status: 'authenticated',\n }\n }\n hadInvalidInteractiveToken = true\n }\n } catch (err) {\n // Transient/unexpected canonical-resolution failures must not invalidate\n // the token \u2014 surface them so we return 'error' (retryable) rather than\n // clearing the session cookies.\n if (err instanceof AuthResolutionUnavailableError) hadUnavailableResolution = true\n else hadInvalidInteractiveToken = true\n }\n }\n\n const resolveUnauthenticatedStatus = (): AuthResolutionStatus =>\n hadUnavailableResolution ? 'error' : hadInvalidInteractiveToken ? 'invalid' : 'missing'\n\n const apiKey = extractApiKey(req)\n if (!apiKey) {\n return { auth: null, status: resolveUnauthenticatedStatus() }\n }\n let apiAuth: AuthContext\n try {\n apiAuth = await resolveApiKeyAuth(apiKey)\n } catch (err) {\n // Only a transient canonical-resolution failure maps to 'error' (retryable\n // 503), mirroring the interactive-token path above. Anything else is an\n // unexpected bug \u2014 rethrow so it surfaces as a 500 rather than being masked\n // as an auth failure.\n if (!(err instanceof AuthResolutionUnavailableError)) {\n throw err\n }\n hadUnavailableResolution = true\n return { auth: null, status: resolveUnauthenticatedStatus() }\n }\n if (!apiAuth) {\n return { auth: null, status: resolveUnauthenticatedStatus() }\n }\n return {\n auth: applySuperAdminScope(apiAuth, tenantCookie, orgCookie),\n status: 'authenticated',\n }\n}\n\nexport async function getAuthFromRequest(req: Request): Promise<AuthContext> {\n return (await resolveAuthFromRequestDetailed(req)).auth\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.7072.1.19c2a8bbe0";
1
+ const APP_VERSION = "0.6.8-develop.7076.1.13d0af067b";
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.7072.1.19c2a8bbe0';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.7076.1.13d0af067b';\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.7072.1.19c2a8bbe0",
3
+ "version": "0.6.8-develop.7076.1.13d0af067b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -109,7 +109,7 @@
109
109
  "@mikro-orm/core": "^7.1.8",
110
110
  "@mikro-orm/decorators": "^7.1.8",
111
111
  "@mikro-orm/postgresql": "^7.1.8",
112
- "@open-mercato/cache": "0.6.8-develop.7072.1.19c2a8bbe0",
112
+ "@open-mercato/cache": "0.6.8-develop.7076.1.13d0af067b",
113
113
  "@types/html-to-text": "^9.0.4",
114
114
  "@types/sanitize-html": "^2.16.1",
115
115
  "dotenv": "^17.4.2",
@@ -470,3 +470,16 @@ describe('jwt helpers', () => {
470
470
  })
471
471
  })
472
472
  })
473
+
474
+ describe('isMfaPendingJwtPayload', () => {
475
+ it('flags pending challenge payloads only', async () => {
476
+ const { isMfaPendingJwtPayload } = await import('../jwt')
477
+ expect(isMfaPendingJwtPayload({ mfa_pending: true, mfa_verified: false })).toBe(true)
478
+ expect(isMfaPendingJwtPayload({ mfa_pending: true })).toBe(true)
479
+ expect(isMfaPendingJwtPayload({ mfa_pending: true, mfa_verified: true })).toBe(false)
480
+ expect(isMfaPendingJwtPayload({ mfa_pending: false, mfa_verified: true })).toBe(false)
481
+ expect(isMfaPendingJwtPayload({})).toBe(false)
482
+ expect(isMfaPendingJwtPayload(null)).toBe(false)
483
+ expect(isMfaPendingJwtPayload('token')).toBe(false)
484
+ })
485
+ })
@@ -0,0 +1,69 @@
1
+ import {
2
+ isMfaPendingAccessAllowed,
3
+ listMfaPendingAccessRoutes,
4
+ registerMfaPendingAccessRoutes,
5
+ } from '../mfaPendingAccess'
6
+
7
+ describe('mfaPendingAccess', () => {
8
+ it('ships an empty generic registry and denies unknown routes by default', () => {
9
+ const registeredPaths = listMfaPendingAccessRoutes().map((route) => route.path)
10
+ expect(registeredPaths).not.toContain('/api/acme-mfa/complete')
11
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/complete')).toBe(false)
12
+ expect(isMfaPendingAccessAllowed('GET', '/api/customers/people')).toBe(false)
13
+ })
14
+
15
+ it('allows registered completion routes for their exact methods only', () => {
16
+ registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t2/verify', methods: ['POST'] }])
17
+
18
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t2/verify')).toBe(true)
19
+ expect(isMfaPendingAccessAllowed('post', '/api/acme-mfa/t2/verify')).toBe(true)
20
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t2/verify/')).toBe(true)
21
+ expect(isMfaPendingAccessAllowed('GET', '/api/acme-mfa/t2/verify')).toBe(false)
22
+ expect(isMfaPendingAccessAllowed('PUT', '/api/acme-mfa/t2/verify')).toBe(false)
23
+ })
24
+
25
+ it('rejects unregistered paths and near-misses', () => {
26
+ registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t3/recovery', methods: ['POST'] }])
27
+
28
+ expect(isMfaPendingAccessAllowed('POST', '/api/customers/people')).toBe(false)
29
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/unrelated')).toBe(false)
30
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/recovery/impersonate')).toBe(false)
31
+ expect(isMfaPendingAccessAllowed('POST', '/api/auth/login')).toBe(false)
32
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t3/recovery-codes')).toBe(false)
33
+ })
34
+
35
+ it('is fail-closed on missing or malformed input', () => {
36
+ registerMfaPendingAccessRoutes([{ path: '/api/acme-mfa/t4/complete', methods: ['POST'] }])
37
+
38
+ expect(isMfaPendingAccessAllowed(undefined, '/api/acme-mfa/t4/complete')).toBe(false)
39
+ expect(isMfaPendingAccessAllowed(null, '/api/acme-mfa/t4/complete')).toBe(false)
40
+ expect(isMfaPendingAccessAllowed('', '')).toBe(false)
41
+ expect(isMfaPendingAccessAllowed('POST', undefined)).toBe(false)
42
+ expect(isMfaPendingAccessAllowed('POST', 'not-a-path')).toBe(false)
43
+ })
44
+
45
+ it('registers additional routes additively and idempotently', () => {
46
+ const routeCountBefore = listMfaPendingAccessRoutes().length
47
+
48
+ registerMfaPendingAccessRoutes([
49
+ { path: '/api/acme-mfa/t5/complete', methods: ['post', 'POST'] },
50
+ ])
51
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t5/complete')).toBe(true)
52
+
53
+ registerMfaPendingAccessRoutes([
54
+ { path: '/api/acme-mfa/t5/complete/', methods: ['POST'] },
55
+ { path: '/api/acme-mfa/t5/complete', methods: ['GET'] },
56
+ ])
57
+ const merged = listMfaPendingAccessRoutes().find((route) => route.path === '/api/acme-mfa/t5/complete')
58
+ expect(merged).toBeDefined()
59
+ expect(merged?.methods).toEqual(['GET', 'POST'])
60
+ expect(listMfaPendingAccessRoutes()).toHaveLength(routeCountBefore + 1)
61
+
62
+ registerMfaPendingAccessRoutes([
63
+ { path: 'no-leading-slash', methods: [] },
64
+ { path: '/api/acme-mfa/t5/broken', methods: [] },
65
+ ] as never)
66
+ expect(listMfaPendingAccessRoutes()).toHaveLength(routeCountBefore + 1)
67
+ expect(isMfaPendingAccessAllowed('POST', '/api/acme-mfa/t5/broken')).toBe(false)
68
+ })
69
+ })
@@ -10,6 +10,7 @@ jest.mock('next/headers', () => ({
10
10
  }))
11
11
 
12
12
  jest.mock('@open-mercato/shared/lib/auth/jwt', () => ({
13
+ ...jest.requireActual('@open-mercato/shared/lib/auth/jwt'),
13
14
  verifyJwt: (...args: unknown[]) => verifyJwt(...args),
14
15
  }))
15
16
 
@@ -162,6 +163,109 @@ describe('auth server integrity checks', () => {
162
163
  await expect(getAuthFromRequest(request)).resolves.toBeNull()
163
164
  })
164
165
 
166
+ describe('mfa-pending staff tokens', () => {
167
+ const pendingAuth = {
168
+ sub: '11111111-1111-4111-8111-111111111111',
169
+ sid: '44444444-4444-4444-8444-444444444444',
170
+ tenantId: '22222222-2222-4222-8222-222222222222',
171
+ orgId: '33333333-3333-4333-8333-333333333333',
172
+ roles: ['admin'],
173
+ mfa_pending: true,
174
+ mfa_verified: false,
175
+ }
176
+
177
+ function mfaPendingRequest(path: string, method: string): Request {
178
+ return new Request(`https://example.test${path}`, {
179
+ method,
180
+ headers: { authorization: `Bearer pending-token` },
181
+ })
182
+ }
183
+
184
+ beforeEach(async () => {
185
+ // The registry ships empty; completion routes are registered by their owning module
186
+ // (enterprise security). Register equivalent fixtures to exercise resolver mechanics.
187
+ const { registerMfaPendingAccessRoutes } = await import('../mfaPendingAccess')
188
+ registerMfaPendingAccessRoutes([
189
+ { path: '/api/security/mfa/prepare', methods: ['POST'] },
190
+ { path: '/api/security/mfa/verify', methods: ['POST'] },
191
+ { path: '/api/security/mfa/recovery', methods: ['POST'] },
192
+ ])
193
+ verifyJwt.mockReturnValue(pendingAuth)
194
+ resolveCanonicalStaffAuthContext.mockResolvedValue({ ...pendingAuth })
195
+ })
196
+
197
+ it('rejects a pending token on a general staff API before roles are restored', async () => {
198
+ const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
199
+
200
+ await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/customers/people', 'GET')))
201
+ .resolves.toEqual({ auth: null, status: 'invalid' })
202
+ expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
203
+ })
204
+
205
+ it('still resolves a pending token on the registered MFA completion routes (POST)', async () => {
206
+ const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
207
+
208
+ for (const path of ['/api/security/mfa/prepare', '/api/security/mfa/verify', '/api/security/mfa/recovery']) {
209
+ resolveCanonicalStaffAuthContext.mockClear()
210
+ const resolution = await resolveAuthFromRequestDetailed(mfaPendingRequest(path, 'POST'))
211
+ expect(resolution.status).toBe('authenticated')
212
+ expect(resolution.auth).toEqual({ ...pendingAuth })
213
+ expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, pendingAuth)
214
+ }
215
+ })
216
+
217
+ it('rejects a pending token with a non-completion method on a completion route', async () => {
218
+ const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
219
+
220
+ await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/security/mfa/verify', 'GET')))
221
+ .resolves.toEqual({ auth: null, status: 'invalid' })
222
+ expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
223
+ })
224
+
225
+ it('resolves the verified replacement token like a normal login', async () => {
226
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
227
+ const verifiedAuth = { ...pendingAuth, mfa_pending: false, mfa_verified: true }
228
+ verifyJwt.mockReturnValue(verifiedAuth)
229
+ resolveCanonicalStaffAuthContext.mockResolvedValue({ ...verifiedAuth, roles: ['employee'] })
230
+
231
+ const request = new Request('https://example.test/api/customers/people', {
232
+ headers: { authorization: 'Bearer verified-token' },
233
+ })
234
+
235
+ await expect(getAuthFromRequest(request)).resolves.toEqual({ ...verifiedAuth, roles: ['employee'] })
236
+ expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, verifiedAuth)
237
+ })
238
+
239
+ it('leaves tokens without MFA claims untouched', async () => {
240
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
241
+ const plainAuth = {
242
+ sub: '11111111-1111-4111-8111-111111111111',
243
+ tenantId: '22222222-2222-4222-8222-222222222222',
244
+ orgId: '33333333-3333-4333-8333-333333333333',
245
+ roles: [],
246
+ }
247
+ verifyJwt.mockReturnValue(plainAuth)
248
+ resolveCanonicalStaffAuthContext.mockResolvedValue(plainAuth)
249
+
250
+ const request = new Request('https://example.test/api/test', {
251
+ headers: { cookie: 'auth_token=jwt-token' },
252
+ })
253
+
254
+ await expect(getAuthFromRequest(request)).resolves.toEqual(plainAuth)
255
+ })
256
+
257
+ it('rejects a pending token from cookie-based page resolution unconditionally', async () => {
258
+ const { getAuthFromCookies, resolveAuthFromCookiesDetailed } = await import('@open-mercato/shared/lib/auth/server')
259
+ cookieStore.get.mockImplementation((name: string) => {
260
+ if (name === 'auth_token') return { value: 'pending-jwt-token' }
261
+ return undefined
262
+ })
263
+
264
+ await expect(getAuthFromCookies()).resolves.toBeNull()
265
+ await expect(resolveAuthFromCookiesDetailed()).resolves.toEqual({ auth: null, status: 'invalid' })
266
+ expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
267
+ })
268
+ })
165
269
  it('reports a transient DB failure on the api-key path as "error" (retryable 503)', async () => {
166
270
  const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
167
271
  // Distinct secret so the shared api-key auth cache does not serve a prior miss.
@@ -377,3 +377,20 @@ export function signAudienceJwt(
377
377
  export function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {
378
378
  return verifyJwt(token, { audience })
379
379
  }
380
+
381
+ /**
382
+ * True when a verified JWT payload represents an MFA challenge in progress: the second factor
383
+ * has not been completed yet (`mfa_pending: true`, `mfa_verified` not `true`). Such tokens are
384
+ * provisional — general staff APIs must reject them and only the MFA completion routes may
385
+ * accept them (see `@open-mercato/shared/lib/auth/mfaPendingAccess`).
386
+ *
387
+ * The predicate keys on `mfa_pending` alone by design. `mfa_verified` is NOT a durable session
388
+ * property: routes that legitimately re-sign a staff token (`PUT /api/auth/profile`,
389
+ * `GET|POST /api/auth/session/refresh`) rebuild the claims without it. Tightening this to require
390
+ * `mfa_verified === true` would therefore silently downgrade those sessions.
391
+ */
392
+ export function isMfaPendingJwtPayload(payload: unknown): boolean {
393
+ if (!payload || typeof payload !== 'object') return false
394
+ const record = payload as Record<string, unknown>
395
+ return record.mfa_pending === true && record.mfa_verified !== true
396
+ }
@@ -0,0 +1,70 @@
1
+ export type MfaPendingAccessRoute = {
2
+ path: string
3
+ methods: string[]
4
+ }
5
+
6
+ const registeredMethodsByPath = new Map<string, Set<string>>()
7
+
8
+ function normalizePath(path: string): string | null {
9
+ if (typeof path !== 'string') return null
10
+ const trimmed = path.trim()
11
+ if (!trimmed || !trimmed.startsWith('/')) return null
12
+ return (trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed).toLowerCase() || '/'
13
+ }
14
+
15
+ function normalizeMethods(methods: unknown): string[] {
16
+ if (!Array.isArray(methods)) return []
17
+ const normalized = methods
18
+ .filter((method): method is string => typeof method === 'string' && method.trim().length > 0)
19
+ .map((method) => method.trim().toUpperCase())
20
+ return Array.from(new Set(normalized)).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0))
21
+ }
22
+
23
+ /**
24
+ * Register routes that may accept an MFA-pending staff token. The registry starts empty and
25
+ * stays generic: every module that completes an MFA challenge on its own endpoints registers
26
+ * them here during its bootstrap — the canonical `/api/security/mfa/*` completion routes are
27
+ * registered by the enterprise `security` module (`lib/mfaCompletionRoutes.ts`), third-party
28
+ * MFA implementations register theirs the same way. Registration is additive: methods merge
29
+ * into any existing entry for the same path and are never removed.
30
+ *
31
+ * Contract defined in `.ai/specs/enterprise/implemented/SPEC-ENT-007-2026-03-06-auth-login-interceptors-extension.md`
32
+ * (§ Amendment 2026-08-21 — central MFA-pending token gate).
33
+ */
34
+ export function registerMfaPendingAccessRoutes(routes: MfaPendingAccessRoute[]): void {
35
+ for (const route of Array.isArray(routes) ? routes : []) {
36
+ const path = normalizePath(route?.path ?? '')
37
+ const methods = normalizeMethods(route?.methods)
38
+ if (!path || !methods.length) continue
39
+ const existing = registeredMethodsByPath.get(path)
40
+ if (!existing) {
41
+ registeredMethodsByPath.set(path, new Set(methods))
42
+ continue
43
+ }
44
+ for (const method of methods) existing.add(method)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Fail-closed check deciding whether an MFA-pending staff token may be resolved for this exact
50
+ * method + path pair. Only explicitly registered completion routes pass; every unregistered
51
+ * request is denied.
52
+ */
53
+ export function isMfaPendingAccessAllowed(
54
+ method: string | null | undefined,
55
+ pathname: string | null | undefined,
56
+ ): boolean {
57
+ const path = normalizePath(typeof pathname === 'string' ? pathname : '')
58
+ if (!path) return false
59
+ if (typeof method !== 'string' || method.trim().length === 0) return false
60
+ const methods = registeredMethodsByPath.get(path)
61
+ if (!methods) return false
62
+ return methods.has(method.trim().toUpperCase())
63
+ }
64
+
65
+ /** Test/ops helper: current snapshot of the pending-access registry. */
66
+ export function listMfaPendingAccessRoutes(): ReadonlyArray<Readonly<MfaPendingAccessRoute>> {
67
+ return Array.from(registeredMethodsByPath.entries())
68
+ .map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0)) }))
69
+ .sort((first, second) => (first.path < second.path ? -1 : first.path > second.path ? 1 : 0))
70
+ }
@@ -1,9 +1,18 @@
1
1
  import { cookies } from 'next/headers.js'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
- import { verifyJwt } from './jwt'
3
+ import { isMfaPendingJwtPayload, verifyJwt } from './jwt'
4
+ import { isMfaPendingAccessAllowed } from './mfaPendingAccess'
4
5
  import { getSharedApiKeyAuthCache } from './apiKeyAuthCache'
5
6
  import { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'
6
7
 
8
+ function readRequestPathname(req: Request): string | null {
9
+ try {
10
+ return new URL(req.url).pathname
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
7
16
  const TENANT_COOKIE_NAME = 'om_selected_tenant'
8
17
  const ORGANIZATION_COOKIE_NAME = 'om_selected_org'
9
18
  const ALL_ORGANIZATIONS_COOKIE_VALUE = '__all__'
@@ -59,6 +68,16 @@ export type TrustedAuthContextEnvelope = {
59
68
  status?: AuthResolutionStatus
60
69
  }
61
70
 
71
+ /**
72
+ * Attach an already-resolved auth context to a synthetic request.
73
+ *
74
+ * INVARIANT callers MUST uphold: `envelope.auth` has to be the product of a gated resolution —
75
+ * `getAuthFromRequest` / `getAuthFromCookies` (or an equivalently validated principal such as an
76
+ * API-key record). The envelope short-circuits `resolveAuthFromRequestDetailed` ahead of both the
77
+ * MFA-pending gate and `resolveCanonicalStaffAuthContext`, so an envelope built straight from an
78
+ * unvalidated JWT would reinstate the MFA bypass this module closes and skip session-revocation
79
+ * checks as well. Never populate it from raw request credentials.
80
+ */
62
81
  export function attachTrustedAuthContext(
63
82
  request: Request,
64
83
  envelope: TrustedAuthContextEnvelope
@@ -353,6 +372,9 @@ export async function resolveAuthFromCookiesDetailed(): Promise<AuthResolution>
353
372
  const payload = verifyJwt(token) as AuthContext
354
373
  if (!payload) return { auth: null, status: 'invalid' }
355
374
  if (payload.type === 'customer') return { auth: null, status: 'invalid' }
375
+ // MFA-pending tokens are provisional: pages never complete the second factor, so they
376
+ // must not resolve to an authenticated context anywhere in the RSC/page flow.
377
+ if (isMfaPendingJwtPayload(payload)) return { auth: null, status: 'invalid' }
356
378
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
357
379
  if (!canonicalAuth) return { auth: null, status: 'invalid' }
358
380
  const tenantCookie = cookieStore.get(TENANT_COOKIE_NAME)?.value
@@ -372,6 +394,8 @@ export async function getAuthFromCookies(): Promise<AuthContext> {
372
394
  }
373
395
 
374
396
  export async function resolveAuthFromRequestDetailed(req: Request): Promise<AuthResolution> {
397
+ // Deliberately ahead of the MFA-pending gate below: the envelope carries a context that was
398
+ // already gated by its producer (see `attachTrustedAuthContext`), not raw request credentials.
375
399
  const trusted = readTrustedAuthContext(req)
376
400
  if (trusted) {
377
401
  return {
@@ -395,6 +419,14 @@ export async function resolveAuthFromRequestDetailed(req: Request): Promise<Auth
395
419
  try {
396
420
  const payload = verifyJwt(token) as AuthContext
397
421
  if (payload && payload.type === 'customer') return { auth: null, status: 'invalid' }
422
+ if (payload && isMfaPendingJwtPayload(payload)) {
423
+ // MFA-pending tokens authenticate only the registered challenge-completion routes;
424
+ // everywhere else they resolve to `invalid` so the dispatcher answers 401 (clearing
425
+ // staff auth cookies) instead of restoring the account's roles/features.
426
+ if (!isMfaPendingAccessAllowed(req.method, readRequestPathname(req))) {
427
+ return { auth: null, status: 'invalid' }
428
+ }
429
+ }
398
430
  if (payload) {
399
431
  const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
400
432
  if (canonicalAuth) {