{"version":3,"sources":["../src/paths.ts","../src/auth.ts"],"names":["createRemoteJWKSet","createLocalJWKSet","jwtVerify"],"mappings":"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","file":"auth.cjs","sourcesContent":["/**\n * The single source of truth for Hanzo IAM OIDC endpoint paths.\n *\n * Hanzo IAM is a Casdoor-derived OIDC provider served per-brand from a\n * configurable origin (`serverUrl`): hanzo → https://iam.hanzo.ai,\n * lux → https://lux.id, zoo → https://zoo.id, bootnode → https://id.bootno.de.\n *\n * These are the ONLY paths. There is no legacy `/oauth/*` and no\n * `/api/login/*`. Every module in this SDK references `OIDC_PATHS` —\n * no path string is written anywhere else.\n *\n * CRITICAL GOTCHA: IAM serves a 200 `text/html` SPA catch-all for ANY\n * unregistered path. A client that hits a wrong path therefore gets a\n * 200 HTML body, not a 404 — silent breakage. So clients MUST hit these\n * exact paths, and a discovery round-trip must never be allowed to\n * resolve to a different path. The hard-coded fallbacks here are these\n * same values, so a failed discovery degrades to correct paths.\n */\n\n/** OIDC endpoint paths, relative to the brand `serverUrl`. */\nexport const OIDC_PATHS = {\n  /** OIDC discovery document. */\n  discovery: \"/.well-known/openid-configuration\",\n  /** Authorization endpoint (RFC 6749 §3.1). */\n  authorize: \"/v1/iam/oauth/authorize\",\n  /** Token endpoint (RFC 6749 §3.2). */\n  token: \"/v1/iam/oauth/token\",\n  /** UserInfo endpoint (OIDC Core §5.3). */\n  userinfo: \"/v1/iam/oauth/userinfo\",\n  /** JWKS endpoint (RFC 7517). */\n  jwks: \"/v1/iam/.well-known/jwks\",\n  /** RP-initiated logout endpoint (OIDC RP-Initiated Logout). */\n  logout: \"/v1/iam/oauth/logout\",\n} as const;\n\nexport type OidcPathKey = keyof typeof OIDC_PATHS;\n\n/**\n * Hanzo-IAM application paths that are NOT part of the OIDC spec — the\n * auth-method discovery endpoint and the onboarding state machine the\n * embedded views drive. Mounted under the same `/v1/iam` prefix.\n */\nexport const IAM_PATHS = {\n  /** Live list of enabled auth methods for the embedded login views. */\n  authMethods: \"/v1/iam/auth/methods\",\n  /** Onboarding state-machine base (steps append `/identity`, etc.). */\n  onboarding: \"/v1/iam/onboarding\",\n  /**\n   * Casdoor credential-login endpoint the embedded `<Login>` views POST to.\n   * `type=code` (a client `redirectUri` is present) mints an authorization\n   * code returned in `data`; `type=login` establishes the session cookie.\n   * This is what the deployed IAM actually authenticates against — the OIDC\n   * token endpoint's password/OTP grants are NOT enabled per-client.\n   */\n  login: \"/v1/iam/login\",\n  /** Send an email/SMS verification code (passwordless login). */\n  sendCode: \"/v1/iam/send-verification-code\",\n  /** Account registration. */\n  signup: \"/v1/iam/signup\",\n} as const;\n\nexport type IamPathKey = keyof typeof IAM_PATHS;\n\n/**\n * The canonical `serverUrl` origin for each Hanzo IAM brand. White-label\n * is host-based: one IAM deployment serves every brand and selects the\n * tenant by the origin it is reached on. This is the SINGLE place the\n * brand→origin mapping lives — adapters take a `brand` and resolve here\n * rather than each app hard-coding a hostname.\n */\nexport const BRAND_SERVER_URLS = {\n  hanzo: \"https://iam.hanzo.ai\",\n  lux: \"https://lux.id\",\n  zoo: \"https://zoo.id\",\n  bootnode: \"https://id.bootno.de\",\n  pars: \"https://pars.id\",\n} as const;\n\n/** A known Hanzo IAM brand key. */\nexport type IamBrand = keyof typeof BRAND_SERVER_URLS;\n\n/**\n * Resolve a brand to its canonical IAM `serverUrl`.\n *\n * @example\n * serverUrlForBrand(\"lux\") // → \"https://lux.id\"\n */\nexport function serverUrlForBrand(brand: IamBrand): string {\n  return BRAND_SERVER_URLS[brand];\n}\n\n/** Strip trailing slashes from a server origin so paths concat cleanly. */\nexport function trimServerUrl(serverUrl: string): string {\n  return serverUrl.replace(/\\/+$/, \"\");\n}\n\n/**\n * Build an absolute IAM endpoint URL from a server origin and a path key.\n *\n * @example\n * iamUrl(\"https://iam.hanzo.ai\", \"token\") // → \"https://iam.hanzo.ai/v1/iam/oauth/token\"\n */\nexport function iamUrl(serverUrl: string, key: OidcPathKey): string {\n  return `${trimServerUrl(serverUrl)}${OIDC_PATHS[key]}`;\n}\n","/**\n * JWT validation using jose library + OIDC JWKS discovery.\n *\n * Validates access/ID tokens issued by Hanzo IAM. The JWKS URI is taken\n * from the live OIDC discovery document (which points at the canonical\n * `/v1/iam/.well-known/jwks` endpoint); the issuer is verified.\n *\n * Audience is verified against `config.clientId` by default. A token\n * whose `aud` does not match is REJECTED — unless the caller explicitly\n * opts out with `allowMissingAudience: true` (for IAM deployments that\n * issue access tokens without an `aud` claim). There is no silent\n * fall-through.\n */\n\nimport {\n  createLocalJWKSet,\n  createRemoteJWKSet,\n  jwtVerify,\n  type JSONWebKeySet,\n  type JWTPayload,\n} from \"jose\";\nimport type { Config, AuthResult, JwtClaims } from \"./types.js\";\nimport { OIDC_PATHS, trimServerUrl } from \"./paths.js\";\n\n// ---------------------------------------------------------------------------\n// JWKS key set cache (per issuer)\n// ---------------------------------------------------------------------------\n\nconst jwksSets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\nfunction getJwksKeySet(jwksUri: string): ReturnType<typeof createRemoteJWKSet> {\n  let keySet = jwksSets.get(jwksUri);\n  if (!keySet) {\n    keySet = createRemoteJWKSet(new URL(jwksUri));\n    jwksSets.set(jwksUri, keySet);\n  }\n  return keySet;\n}\n\n// Fallback local key sets, deduped by kid. A spec-compliant JWKS has one key\n// per `kid` (RFC 7517 §4.5), but some issuers publish the SAME key twice under\n// one kid (e.g. an identical cert collected from a global + an org-scoped\n// owner). jose's remote set then can't pick one and throws\n// `JWKSMultipleMatchingKeys`, which would hard-fail verification of EVERY token\n// signed with that kid. We dedupe by kid and verify against a local set so a\n// malformed-but-harmless JWKS never breaks login. Cached per jwksUri.\nconst localJwksSets = new Map<string, ReturnType<typeof createLocalJWKSet>>();\n\nasync function getDedupedLocalKeySet(\n  jwksUri: string,\n): Promise<ReturnType<typeof createLocalJWKSet>> {\n  const cached = localJwksSets.get(jwksUri);\n  if (cached) return cached;\n\n  const res = await fetch(jwksUri, { headers: { Accept: \"application/json\" } });\n  if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);\n  const jwks = (await res.json()) as JSONWebKeySet;\n\n  const seen = new Set<string>();\n  const keys = (jwks.keys ?? []).filter((k) => {\n    const id = `${k.kid ?? \"\"}:${k.alg ?? \"\"}`;\n    if (seen.has(id)) return false;\n    seen.add(id);\n    return true;\n  });\n\n  const keySet = createLocalJWKSet({ keys });\n  localJwksSets.set(jwksUri, keySet);\n  return keySet;\n}\n\n/** Clear cached JWKS key sets (useful for testing or key rotation). */\nexport function clearJwksCache(): void {\n  jwksSets.clear();\n  localJwksSets.clear();\n}\n\n// ---------------------------------------------------------------------------\n// OIDC discovery cache (lightweight, no full client needed)\n// ---------------------------------------------------------------------------\n\ntype CachedDiscovery = { jwksUri: string; issuer: string; fetchedAt: number };\nconst discoveryCache = new Map<string, CachedDiscovery>();\nconst DISCOVERY_TTL_MS = 5 * 60 * 1000;\n\nasync function resolveJwksUri(serverUrl: string): Promise<{ jwksUri: string; issuer: string }> {\n  const baseUrl = trimServerUrl(serverUrl);\n  const cached = discoveryCache.get(baseUrl);\n  if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) {\n    return { jwksUri: cached.jwksUri, issuer: cached.issuer };\n  }\n\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(), 8_000);\n  try {\n    const res = await fetch(`${baseUrl}${OIDC_PATHS.discovery}`, {\n      signal: controller.signal,\n      headers: { Accept: \"application/json\" },\n    });\n    if (!res.ok) {\n      throw new Error(`OIDC discovery failed: ${res.status}`);\n    }\n    const body = (await res.json()) as { jwks_uri?: string; issuer?: string };\n    const jwksUri = body.jwks_uri;\n    const issuer = body.issuer ?? baseUrl;\n    if (!jwksUri) {\n      throw new Error(\"OIDC discovery response missing jwks_uri\");\n    }\n    discoveryCache.set(baseUrl, { jwksUri, issuer, fetchedAt: Date.now() });\n    return { jwksUri, issuer };\n  } finally {\n    clearTimeout(timer);\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Token validation\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a JWT access token against IAM's JWKS.\n *\n * Uses OIDC discovery to find the JWKS URI, then verifies the token\n * signature, issuer, audience, and expiry using the `jose` library.\n *\n * The audience MUST equal `config.clientId`. A mismatch fails with\n * `iam_audience_invalid` unless `config.allowMissingAudience === true`,\n * in which case the audience check is skipped entirely (for IAM\n * deployments that issue access tokens without an `aud` claim). There is\n * no automatic, silent retry.\n */\nexport async function validateToken(\n  token: string,\n  config: Config,\n): Promise<AuthResult> {\n  if (!token || typeof token !== \"string\") {\n    return { ok: false, reason: \"iam_token_missing\" };\n  }\n\n  let jwksUri: string;\n  let issuer: string;\n  if (config.jwksUri && config.issuer) {\n    // Explicit overrides — bypass OIDC discovery entirely. This is the\n    // in-cluster / split-horizon path: the pod can't reach the public IAM\n    // origin (LB hairpin / WAF 403) and discovery would advertise an\n    // unreachable public jwks_uri + a mismatched issuer, so the caller pins\n    // a reachable in-cluster JWKS endpoint and the real public issuer.\n    jwksUri = config.jwksUri;\n    issuer = config.issuer;\n  } else {\n    try {\n      const discovery = await resolveJwksUri(config.serverUrl);\n      jwksUri = discovery.jwksUri;\n      issuer = discovery.issuer;\n    } catch {\n      return { ok: false, reason: \"iam_discovery_failed\" };\n    }\n  }\n\n  const keySet = getJwksKeySet(jwksUri);\n  const verifyOptions = {\n    issuer,\n    // Skip aud verification only when explicitly allowed; otherwise\n    // pin to the client id. No silent fall-through on mismatch.\n    ...(config.allowMissingAudience ? {} : { audience: config.clientId }),\n    clockTolerance: 30, // 30s clock skew\n  };\n\n  let payload: JWTPayload;\n  try {\n    let result;\n    try {\n      result = await jwtVerify(token, keySet, verifyOptions);\n    } catch (err) {\n      // A JWKS that lists the token's kid more than once makes jose's remote\n      // set ambiguous (`JWKSMultipleMatchingKeys`). Retry ONCE against a local\n      // set deduped by kid — the only key for that kid then verifies cleanly.\n      // Any other error propagates to the mapping below unchanged.\n      if ((err as { code?: string }).code === \"ERR_JWKS_MULTIPLE_MATCHING_KEYS\") {\n        const localSet = await getDedupedLocalKeySet(jwksUri);\n        result = await jwtVerify(token, localSet, verifyOptions);\n      } else {\n        throw err;\n      }\n    }\n    payload = result.payload;\n  } catch (err) {\n    // Branch on jose's typed error codes, not message strings.\n    // jose v6: ERR_JWT_EXPIRED for `exp`; ERR_JWT_CLAIM_VALIDATION_FAILED\n    // with `claim === \"aud\"` for an audience mismatch.\n    const code = (err as { code?: string }).code;\n    const claim = (err as { claim?: string }).claim;\n    if (code === \"ERR_JWT_EXPIRED\") {\n      return { ok: false, reason: \"iam_token_expired\" };\n    }\n    if (code === \"ERR_JWT_CLAIM_VALIDATION_FAILED\" && claim === \"aud\") {\n      return { ok: false, reason: \"iam_audience_invalid\" };\n    }\n    return { ok: false, reason: \"iam_signature_invalid\" };\n  }\n\n  const claims = payload as unknown as JwtClaims;\n\n  // Hanzo IAM tokens may use owner/name instead of sub claim\n  const sub =\n    claims.sub ||\n    (typeof claims.owner === \"string\" && typeof claims.name === \"string\"\n      ? `${claims.owner}/${claims.name}`\n      : undefined);\n\n  if (!sub) {\n    return { ok: false, reason: \"iam_subject_missing\" };\n  }\n\n  // IAM sub format is \"org/username\" - extract owner\n  const parts = sub.split(\"/\");\n  const owner = parts.length > 1 ? parts[0] : config.orgName ?? \"unknown\";\n\n  return {\n    ok: true,\n    userId: sub,\n    email: typeof claims.email === \"string\" ? claims.email : undefined,\n    name:\n      typeof claims.name === \"string\"\n        ? claims.name\n        : typeof claims.preferred_username === \"string\"\n          ? claims.preferred_username\n          : undefined,\n    avatar: typeof claims.picture === \"string\" ? claims.picture : undefined,\n    owner,\n    claims,\n  };\n}\n"]}