Skip to content

feat(backend): myinfo v5#9573

Draft
eliotlim wants to merge 5 commits into
developfrom
feat/myinfo-v5
Draft

feat(backend): myinfo v5#9573
eliotlim wants to merge 5 commits into
developfrom
feat/myinfo-v5

Conversation

@eliotlim

@eliotlim eliotlim commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Myinfo V3 APIs will be deprecated soon.

Closes FRM-2442.

Solution

Breaking Changes

  • No - this PR is backwards compatible

Features:

  • Add Myinfo V5 support
  • Add feature flag to redirect users to Myinfo V5

Tests

TC1: Submit a form with Myinfo V5 enabled

  • Open a Storage Mode / MRF form with Myinfo V5 feature-flag enabled
  • Fill and Submit the form
  • The submission should succeed

TC2: Submit a form with Myinfo V5 enabled

  • Open a Storage Mode / MRF form with Myinfo V5 feature-flag disabled
  • Fill and Submit the form
  • The submission should succeed

Deploy Notes

New environment variables:

  • env var : env var details

New feature flags:

  • env var : env var details

New scripts:

  • script : script details

New dependencies:

  • dependency : dependency details

New dev dependencies:

  • dependency : dependency details

@linear

linear Bot commented Jun 9, 2026

Copy link
Copy Markdown

FRM-2442

eliotlim and others added 4 commits June 9, 2026 08:50
Adds a Singpass Auth API v5 / MyInfo v5 implementation alongside the
existing v3 client, dispatched per-request via the switch-myinfo-v5
GrowthBook flag. The version is invisible to form admins — toggling the
flag flips the whole flow (redirect, callback, prefill) for users.

Why:
v3 is decommissioned 2026-09-30. v5 collapses the Person endpoint into
the OIDC userinfo flow with ES256 / ECDH-ES crypto and PKCE — none of
which @opengovsg/myinfo-gov-client (the v3 client we ship today)
supports.

Approach:
- New MyInfoV5ServiceClass implements discovery, PKCE auth URL,
  private_key_jwt token exchange, userinfo JWE decrypt + JWS verify.
  Issuer URL is parameterized so mockpass (singpass/v2) and prod share
  one code path.
- New routes: GET /api/v3/mi/v5/login (callback) and
  GET /api/v3/mi/v5/.well-known/jwks.json (RP public keyset).
- Adapter reshapes v5 userinfo claims into the existing IPerson so
  the prefill/hash pipeline is untouched. Supports flat (mockpass) and
  person_info / sub_attributes (prod) envelopes.
- Dispatcher branches at two points only: redirect-URL generation
  (flag-gated) and form-view prefill (PKCE-cookie-gated). Cookie schema
  for v3 forms is unchanged.
- Soft-disables when JWKS or issuer are unset, so a partial deploy
  falls back to v3 rather than breaking logins.
- mockpass bumped 4.6.4 to 4.6.7 to pick up v5 userinfo mocking. Dev
  JWKS endpoint merges SPCP OIDC + v5 keys so both flows work against
  a single mockpass instance.

Tested:
- 19 unit tests covering the adapter (envelope handling, scope mapping)
  and crypto (RFC 7636 PKCE challenge, JWT round-trip).
- End-to-end smoke against mockpass 4.6.7 — PKCE, token exchange,
  userinfo JWE decrypt, JWS verify, MyInfoData prefill. See
  apps/backend/scripts/smoke-myinfo-v5.ts.

Known follow-ups before prod cutover:
- Bearer userinfo today; prod v5 may require DPoP (TODO marker in code).
- Mockpass only mocks a subset of MyInfo attributes (uinfin/name/...).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Production Singpass v5 requires RFC 9449 DPoP for the token + userinfo
endpoints. Adds DPoP behind MYINFO_V5_DPOP_ENABLED so dev/mockpass keep
working under Bearer auth while prod can flip the env var to enable
DPoP at deploy time.

Per-session, not per-process:
The DPoP private key MUST persist across the redirect to callback round
trip, and FormSG runs multi-pod — a user's two requests can land on
different pods. So the DPoP keypair lives in a short-TTL Mongo doc
keyed by an opaque session id in a cookie, alongside the PKCE verifier.
On the inbound callback we re-import the JWK and use it for the token
exchange + userinfo call.

Pieces:
- myinfo.v5.dpop.ts — generateDpopKeyPair, importDpopKeyPair (rehydrate
  from a stored private JWK), createDpopProof (htm/htu/jti/iat plus
  optional ath for resource endpoints, strips query/fragment from htu
  per RFC 9449 §4.2), computeAccessTokenHash.
- myinfo.v5.session.model.ts — MyInfoV5Session collection with TTL
  index and one-shot consumeSession semantics.
- Service methods now accept dpopKeypair as a per-call arg. When
  dpopEnabled is true and the keypair is missing we fail closed.
- Public-form controller persists the session on redirect and consumes
  it on form-view; replaces the PKCE-only cookie with an opaque
  session-id cookie.

Tests (35 total passing):
- DPoP proof claims, ath, htu normalisation, keypair round-trip.
- Service-level: bearer vs DPoP header selection verified by mocking
  axios and reading the request config. DPoP proof signature verified
  against the supplied keypair's public half.
- Mockpass end-to-end smoke (Bearer path) still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two production-readiness fixes:

Cookie signing
- The v5 session cookie now uses cookie-parser's HMAC signing
  (signed: true). The pattern mirrors the existing stripeState cookie
  and reuses config.sessionSecret so no new key material is needed.
  Reads switch from req.cookies to req.signedCookies; cookie-parser
  surfaces tampered values as false, so a forged session id falls
  through to v3 rather than being blindly trusted.

Nonce verification (OIDC §3.1.3.7)
- Persist the nonce we send on the authorize request into the session
  row (optional field, backward-compat with the existing session shape).
- New verifyIdToken on the v5 service: decrypts the JWE if present,
  verifies the JWS against the IdP's published signing key, and asserts
  the nonce claim matches the expected value. Detects JWS-vs-JWE by
  compact-segment count (3 vs 5) so both prod and mockpass shapes work.
- Form-view path calls verify before fetchUserinfo when both the session
  nonce and the token response's id_token are present. When either is
  missing (older session row mid-deploy, or an IdP that omits id_token)
  we log a warning and continue — this is the "backward-compatible"
  path. A nonce mismatch is a hard fail, since that's the replay-attack
  case we exist to defeat.

Tests (40 total passing):
- 5 new id_token tests: matching nonce in JWE-wrapped + bare-JWS shapes,
  mismatch rejection, malformed segment count, signature-from-wrong-key
  rejection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Security review of the v5 branch surfaced six gaps that should land
before MYINFO_V5_DPOP_ENABLED is flipped on in prod. All addressed
together because they share files and the fixes interact (e.g. encrypt-
at-rest changes the session schema that nonce hardening also reads).

OIDC claim validation (H1, M1)
- Both id_token and userinfo JWS verifications now pin `iss` to the
  discovery doc's issuer and `aud` to our client_id, with 30 s clock
  tolerance. Previously only the JWS signature was checked, so any JWT
  signed by the same JWKS but minted for a different RP would have
  passed. `jose.jwtVerify` defaults checked `exp` but not `iss`/`aud`.

Nonce verification hard-fail (H2)
- Removed the "log warning and continue" path when either
  `session.nonce` or `tokenResp.id_token` is missing. Without nonce
  verification we lose the only defense against auth-code/id_token
  replay, and a soft-fail is exploitable if an attacker can suppress
  the id_token. No backward-compat needed: every session this
  controller produces persists a nonce, and any session predating that
  has already been TTL-expired (5 min).

DPoP private JWK encrypted at rest (M2)
- AES-256-GCM with a key derived from `config.sessionSecret` via
  PBKDF2-SHA256 (100 k iterations), fresh per-record salt + IV.
  Envelope is `v1.<salt>.<iv>.<tag>.<ciphertext>` so we can rotate
  algorithms later without ambiguity. A DB-only compromise can no
  longer replay the bound access token within the 5 min session
  window without also lifting the app secret.
- Session field renamed `dpopPrivateJwk: Object` → `dpopPrivateJwkEnc:
  String`. The String type also collapses the Mongoose Mixed-shape
  schema risk where a tampered row could smuggle arbitrary JWK
  material to `jose.importJWK`. Sentinel `{kty:'oct',k:''}` for the
  no-DPoP path is gone — the field is just absent.

JWK shape check (M3)
- `assertEcP256PrivateJwk` runs inside `importDpopKeyPair` before
  handing the JWK to jose: rejects anything that isn't `kty:'EC' /
  crv:'P-256'` with `x`, `y`, `d` strings. Defense in depth in case
  a future schema migration or a tampered row sneaks the wrong key
  past the type system.

RFC 9449 §8 DPoP-Nonce retry (M5)
- New `withDpopNonceRetry` helper around both the token exchange and
  the userinfo fetch. If the AS/RS returns 400/401 with a
  `DPoP-Nonce` header, we retry once with the nonce baked into a
  fresh DPoP proof. `createDpopProof` accepts a new `nonce?` param.
  The retry can't loop — the lambda is invoked at most twice.

Tests: 40/40 v5 tests still pass. No new test files in this commit;
the existing service / id_token / DPoP suites continue to cover the
hardened code paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@eliotlim eliotlim closed this Jun 9, 2026
@eliotlim eliotlim reopened this Jun 9, 2026
Audit finding M6: a private JWKS file (`dev-rp-secret.json`) was checked
into the repo as a "dev-only" fixture, but the same file was also the
key the docker-compose dev backend signed against. Anyone with repo
read access could mint client_assertions for the dev environment, and a
typo in IaC could in theory point a prod backend at the same path.

Smoke script
- `apps/backend/scripts/smoke-myinfo-v5.ts` now calls
  `jose.generateKeyPair` for both sig (ES256) and enc (ECDH-ES+A256KW)
  on each invocation. The public half is still published at :5099 for
  mockpass to fetch; the private half lives only in process memory.

Docker dev backend
- New script `apps/backend/scripts/generate-dev-rp-keys.ts` writes a
  fresh sig+enc JWKS pair to the paths supplied by
  `MYINFO_V5_RP_JWKS_PUBLIC_PATH` / `_SECRET_PATH`. Idempotent — skips
  if both files already exist, so restarting the container doesn't
  rotate the key under any tokens currently in flight.
- `Dockerfile.development` CMD runs the generator (via `pnpm --filter
  formsg-backend gen-dev-rp-keys`) before `pnpm dev:backend`, so the
  backend always finds the keyset on disk.
- `docker-compose.yml` env vars repointed from
  `src/app/modules/myinfo/v5/__fixtures__/keys/dev-rp-*.json` to
  `.dev-keys/rp-v5-{public,secret}.json`.

Hygiene
- `.dev-keys/` added to `.gitignore` so the generated material can't be
  re-committed by accident.
- Both fixture files and their containing `__fixtures__/keys/` and
  `__fixtures__/` directories are gone. No code references them now.

Tests: 40/40 v5 jest suites still green. Generator round-trip verified
manually (writes well-formed JWKS, skips on second run).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

1 participant