feat(backend): myinfo v5#9573
Draft
eliotlim wants to merge 5 commits into
Draft
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Myinfo V3 APIs will be deprecated soon.
Closes FRM-2442.
Solution
Breaking Changes
Features:
Tests
TC1: Submit a form with Myinfo V5 enabled
TC2: Submit a form with Myinfo V5 enabled
Deploy Notes
New environment variables:
env var: env var detailsNew feature flags:
env var: env var detailsNew scripts:
script: script detailsNew dependencies:
dependency: dependency detailsNew dev dependencies:
dependency: dependency details