/** * Inbound credential verification. * * Uses kernel-core's credential verification infrastructure * (CredentialMethodResolver, MethodRegistry) instead of manual JWT handling. * Not tied to JWT — supports any credential method kernel-core provides. */ import type { Attestation, CredentialInput, Delegation, IssuerId, VerifiedCredential, } from '@astrale-os/kernel-core' import { CredentialMethodResolver, MethodRegistry, SignatureVerificationError, SigningKeyNotFoundError, verifyAudience, verifyCredential, } from '@astrale-os/kernel-core' import { createLocalJWKSet, createRemoteJWKSet, type JWK } from 'jose' import type { RemoteIdentityConfig } from './identity.js' import { derivePublicJwk } from '../server/jwks.js' import { canonicalizeServingUrl } from '../server/serving-url.js' export type VerifiedInbound = { /** The full verified credential (iss, sub, aud, claims) */ verified: VerifiedCredential /** Issuer URL (from credential's iss claim) */ issuer: string /** Attestation — proves caller can invoke this function */ attestation: Attestation /** Delegation — scoped caller permissions as kernel-signed credential */ delegation: Delegation } const methodResolver = new CredentialMethodResolver(new MethodRegistry()) // JWKS resolvers cached per JWKS URL (module-level, like the pools/selfIds // Maps in kernel-client.ts). jose handles freshness WITHIN a resolver: 10min // max-age, 30s fetch cooldown, and auto-refetch on kid-miss when not cooling // down. The one gap — issuer restarts with new keys while the cooldown pins // the old set (the incident that got a prior indefinite cache removed) — is // covered by evict-and-retry-once in `verifyInboundCredential`, so indefinite // Map residency is safe and the per-call JWKS fetch is gone. const remoteResolvers = new Map>() // Self-issued credentials verify against the worker's own in-memory key; // cache the local JWKS per canonical self-issuer (one key per worker) so the // public-JWK derivation isn't redone on every request. const localResolvers = new Map>() /** Clear cached JWKS resolvers. Used in tests when keys rotate between fixtures. */ export function clearJwksCache(): void { remoteResolvers.clear() localResolvers.clear() } /** * M-28 fix: a bare-slug iss (`mails.localhost`) makes * `new URL('mails.localhost/.well-known/jwks.json')` throw "Invalid URL * string". The dispatcher's identity map normalizes the iss it signs with, * but inbound creds from older clients may still carry the slug form. Coerce * to a URL with a default `https://` scheme (matches kernel.astrale.ai * canonical form). If the actual receiver is on http://localhost the * receiver-side resolver still works because both endpoints are on localhost * — but for prod targets requiring TLS this is the right default. */ function jwksUrlFor(issuer: string): string { const normalized = /^https?:\/\//.test(issuer) ? issuer : `https://${issuer}` return `${normalized}/.well-known/jwks.json` } function getRemoteResolver(jwksUrl: string): ReturnType { let resolver = remoteResolvers.get(jwksUrl) if (!resolver) { resolver = createRemoteJWKSet(new URL(jwksUrl)) remoteResolvers.set(jwksUrl, resolver) } return resolver } function getLocalResolver( selfIssuer: string, privateKey: RemoteIdentityConfig['privateKey'], ): ReturnType { let resolver = localResolvers.get(selfIssuer) if (!resolver) { resolver = createLocalJWKSet({ keys: [derivePublicJwk(privateKey) as JWK] }) localResolvers.set(selfIssuer, resolver) } return resolver } /** * Build the key resolver for one verifying server. Captures `config` so it can * short-circuit the server's OWN issuer (`config.issuer`): a self-issued * credential is verified against the in-memory public key, never fetched — a * Worker can't fetch its own hostname, and it already holds the key. Every other * issuer is resolved via the cached per-URL JWKS resolvers above; every JWKS * URL touched is recorded in `resolvedJwksUrls` so the caller can evict * exactly those resolvers if verification fails on an unknown signing key. */ function makeResolveKeys(config: RemoteIdentityConfig, resolvedJwksUrls: Set) { // The worker's own canonical iss. STRICT: `config.issuer` is the serving URL // by contract (both producers — buildIdentityMap / buildAuxIdentityMap — feed // it `canonicalizeServingUrl(config.url)`), so a value that doesn't parse is // a construction-time config error. Swallowing it would silently disable // self-verification and route self-issued credentials to a JWKS fetch on the // worker's own hostname — which Cloudflare forbids — turning a config bug // into an opaque per-call failure. const selfIssuer = canonicalizeServingUrl(config.issuer) return async (issuer: IssuerId, _method: string, _kid?: string) => { const url = issuer as string // Self-issued credential (iss == this worker's own serving URL): resolve // from the in-memory public key. Never fetch self — Cloudflare forbids a // Worker fetching its own hostname, and the published JWKS is this key. if (selfIssuer !== undefined) { let canonical: string | undefined try { canonical = canonicalizeServingUrl(url) } catch { canonical = undefined } if (canonical === selfIssuer) { return getLocalResolver(selfIssuer, config.privateKey) } } const jwksUrl = jwksUrlFor(url) resolvedJwksUrls.add(jwksUrl) return getRemoteResolver(jwksUrl) } } /** * Verify an inbound delegation credential using kernel-core's verification. * * @throws AuthenticationError subclasses from kernel-core on verification failure */ export async function verifyInboundCredential( credential: CredentialInput, config: RemoteIdentityConfig, ): Promise { // Verify using kernel-core's credential verification pipeline const resolvedJwksUrls = new Set() const deps = { methodResolver, resolveKeys: makeResolveKeys(config, resolvedJwksUrls) } let verified: VerifiedCredential try { verified = await verifyCredential(deps, credential) } catch (error) { // A cached resolver can hold a stale key set after the issuer rotates: // - new kid → jose kid-misses but its 30s fetch cooldown blocks the // refetch (SigningKeyNotFoundError) — the incident that got a prior // indefinite cache removed; // - SAME kid, new key material (kernel kids derive from the subject, so // a re-keyed issuer reuses its kid) → the signature check fails // (SignatureVerificationError). // Both: evict the resolver(s) this verification touched and retry ONCE // with fresh ones. Forged-token spam thus costs at most one JWKS fetch // per bad credential — equal to the uncached per-call baseline, never // worse. An empty set means self-issued — refetching can't help, rethrow. const staleKeySuspect = error instanceof SigningKeyNotFoundError || error instanceof SignatureVerificationError if (!staleKeySuspect || resolvedJwksUrls.size === 0) throw error for (const url of resolvedJwksUrls) remoteResolvers.delete(url) verified = await verifyCredential(deps, credential) } // Validate audience matches this function's issuer (its serving URL). // kernel-core's verifyAudience compares canonically. verifyAudience(verified, (config.audience ?? config.issuer) as IssuerId) // Extract attestation and delegation from claims const attestation = verified.claims.attestation as Attestation | undefined if (!attestation?.expr) { throw new Error('Credential missing attestation') } const delegation = verified.claims.delegation as Delegation | undefined if (!delegation?.credential) { throw new Error('Credential missing delegation') } return { verified, issuer: verified.iss as string, attestation, delegation, } }