Skip to content

Topic/external idp jwt verification - #35

Open
wistefan wants to merge 6 commits into
Prometheus-X-association:mainfrom
wistefan:topic/external-idp-jwt-verification
Open

wistefan wants to merge 6 commits into
Prometheus-X-association:mainfrom
wistefan:topic/external-idp-jwt-verification

Conversation

@wistefan

@wistefan wistefan commented Sep 1, 2026

Copy link
Copy Markdown

Fixes #34

Adds asymmetric JWT verification for tokens issued by trusted external IDPs / OID4VP login flows, alongside the existing local HS256 paths which are left unchanged.

wistefan and others added 6 commits August 19, 2026 15:01
Adds asymmetric JWT verification for tokens issued by trusted external
IDPs / OID4VP login flows, alongside the existing local HS256 paths which
are left unchanged.

- config/externalIdp.ts: typed, validated env config (issuers, audience,
  algs, subject claim, discovery TTL); disabled by default; fails fast if
  issuers are set without an audience.
- libs/jwt/externalVerifier.ts: OIDC discovery through the existing cached
  axios, per-issuer remote JWKS (jose) with key rotation, signature + iss
  + aud + exp + alg checks.
- libs/jwt/externalIdentity.ts: iss-based routing (peek only, never
  trusted) and mapping of the external subject (DID) to a local
  User/UserIdentifier/Participant; rejects unknown subjects with 401
  (no JIT provisioning).
- middleware/auth.ts + middleware/oauth.ts: additive external branch in
  verifyUserJWT, verifyParticipantJWT and validateAccessToken.
- errors/UnauthorizedError.ts: 401 typed error.
- Parameterized tests with a mock discovery + JWKS endpoint, and
  middleware routing tests.
- Docs + .env.sample entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the external IDP branch so it adds token verification only, and
leaves the user identity model completely untouched. Preserves the
configurable discovery path and JWKS proxy support from the previous
commits.

Identity mapping:
- The verified subject is matched against User.email and Participant.did.
  User.email is the key the codebase already uses for natural persons and
  is only written when a User is created, so a participant cannot repoint
  an existing account at a subject it controls. The field is a plain
  string, so it can equally hold a DID with no schema change.
- Dropped the UserIdentifier.identifier lookup entirely. Those documents
  are participant-scoped and participant-writable, so matching them let
  the party supplying the value also choose whose account it resolved to.
- An ambiguous match (more than one record) is rejected rather than
  resolved arbitrarily.
- When the subject claim is `email`, `email_verified: true` is required;
  an unverified address is self-asserted and cannot bind an account.

Verification hardening:
- exp/iat and the subject claim are now required to be present, not just
  valid when present, so an issuer omitting exp cannot mint a token that
  never expires.
- EXTERNAL_OIDC_ALGS is validated against an asymmetric allowlist; issuer
  URLs and the advertised jwks_uri must be https; the discovery document
  must describe the issuer it was fetched for.
- Discovery and JWKS requests have a timeout, a short failure backoff, and
  a configurable clock tolerance (EXTERNAL_OIDC_HTTP_TIMEOUT,
  EXTERNAL_OIDC_CLOCK_TOLERANCE). The configured TTL now also drives the
  JWKS cache, which previously used jose's defaults.
- Issuer unreachable is a new IdpUnavailableError mapped to 503, rather
  than reporting a valid credential as 401.
- 401 responses carry a uniform message so they are not an oracle for
  which check failed or whether a subject is enrolled.
- Trailing slashes are normalised on both configured and presented
  issuers, so the two spellings of one issuer no longer diverge between
  routing and verification.

Middleware:
- resolveTokenRoute moved inside the try blocks; on Express 4 a throw from
  an async middleware became an unhandled rejection, so a config error
  hung the request instead of failing at startup. The config is now also
  parsed eagerly in startServer.
- verifyParticipantJWT no longer caches the external identity in the
  session, which had let a session outlive the token.
- Shared authenticateExternalToken/sendExternalAuthError helper replaces
  the branch duplicated across the three middlewares.
- UnauthorizedError and IdpUnavailableError are handled by
  globalErrorHandler.

Packaging:
- http-proxy-agent dropped: jwks_uri is required to be https, so only the
  https agent can ever apply.
- https-proxy-agent added to package-lock.json, where package.json
  declared it but the lockfile had no entry at all - npm ci would refuse
  to install.

Tests:
- Added the missing core case: a trusted `iss` signed by a key outside the
  issuer's JWKS. Also no-exp tokens, discovery issuer mismatch, cleartext
  jwks_uri, discovery errors, and configuration validation.
- resetExternalVerifierCaches now empties the discovery cache storage,
  which had leaked a cached document between cases and made the
  "no jwks_uri" case pass for the wrong reason.
- New externalIdentity.spec.ts covers the mapping, including ambiguity and
  the email_verified gate.
- Middleware tests assert that a failed external verification does not
  fall back to the local HMAC path.

Docs:
- EXTERNAL_OIDC_DISCOVERY_PATH and the HTTPS_PROXY/NO_PROXY behaviour are
  documented in README and .env.sample.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two conflicts, both additive on each side:

- .env.sample: upstream renamed X_VISIONSTRUST_CONSENT_KEY to
  X_CATALOG_CONSENT_KEY. Took the rename and kept the appended
  EXTERNAL_OIDC_* block.
- src/middleware/globalErrorHandler.ts: upstream added a NotFoundError
  branch, an eslint-disable for the unused `next`, and removed the
  unreachable trailing next(err). This branch added UnauthorizedError
  (401) and IdpUnavailableError (503). Kept upstream's structure with all
  three imports and all four error branches; the types are disjoint so the
  chain order carries no meaning.
@wistefan
wistefan marked this pull request as ready for review September 1, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow external JWT issuers, following the OAuth2.0/OIDC spec

1 participant