diff --git a/.gitignore b/.gitignore index 625332bb2d..63ea546039 100755 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,11 @@ dist/ *passphrase* *.key +# MyInfo v5 dev RP keys — generated at container startup by +# `pnpm --filter formsg-backend gen-dev-rp-keys`. Private key material, +# never checked in. +.dev-keys/ + # VSCode # ============== .dccache diff --git a/Dockerfile.development b/Dockerfile.development index f5eeece64b..726a5e54ed 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -58,5 +58,6 @@ EXPOSE 5000 # tini is the init process that will adopt orphaned zombie processes # e.g. chromium when launched to create a new PDF ENTRYPOINT [ "tini", "--" ] -# Create local AWS resources before building the app -CMD sh init-localstack.sh && pnpm dev:backend +# Create local AWS resources, generate dev-only MyInfo v5 RP keys (idempotent +# — skips if already present in the mounted volume), then start the backend. +CMD sh init-localstack.sh && pnpm --filter formsg-backend gen-dev-rp-keys && pnpm dev:backend diff --git a/apps/backend/.eslintrc.js b/apps/backend/.eslintrc.js index ea800d55f2..0091dda688 100644 --- a/apps/backend/.eslintrc.js +++ b/apps/backend/.eslintrc.js @@ -4,7 +4,7 @@ module.exports = { es6: true, node: true, }, - ignorePatterns: [], + ignorePatterns: ['scripts/'], extends: ['eslint:recommended', 'plugin:prettier/recommended'], globals: { Atomics: 'readonly', diff --git a/apps/backend/package.json b/apps/backend/package.json index b0fa627d90..99ef2f7a4d 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -7,6 +7,7 @@ "build": "tsc -p tsconfig.build.json && pnpm copyfiles:backend", "copyfiles:backend": "copyfiles -u 1 src/**/*.html ./dist/src", "dev": "tsnd --poll --respawn --transpile-only --inspect=0.0.0.0 --exit-child -r dotenv/config -- src/app/server.ts", + "gen-dev-rp-keys": "tsx scripts/generate-dev-rp-keys.ts", "start": "node -r dotenv/config dist/src/app/server.js", "test:e2e-v2:server": "env-cmd -f ./__tests__/setup/.test-env pnpm test-e2e-server", "test-e2e-server": "concurrently --success last --kill-others \"pnpm exec mockpass\" \"pnpm exec maildev\" \"node dist/src/app/server.js\" \"node ./__tests__/setup/mock-webhook-server.js\"", @@ -17,7 +18,6 @@ "lint-ci": "pnpm exec eslint src/ --quiet" }, "dependencies": { - "formsg-shared": "workspace:*", "@aws-sdk/client-cloudwatch-logs": "^3.758.0", "@aws-sdk/client-lambda": "^3.693.0", "@aws-sdk/client-s3": "^3.775.0", @@ -64,6 +64,7 @@ "express-session": "^1.18.2", "express-winston": "^4.2.0", "file-saver": "^2.0.5", + "formsg-shared": "workspace:*", "fp-ts": "^2.16.9", "helmet": "^8.1.0", "hot-shots": "^10.1.1", @@ -200,6 +201,7 @@ "ts-loader": "^8.2.0", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", + "tsx": "^4.22.3", "type-fest": "^4.17.0", "typescript": "^5.4.5", "worker-loader": "^2.0.0" diff --git a/apps/backend/scripts/generate-dev-rp-keys.ts b/apps/backend/scripts/generate-dev-rp-keys.ts new file mode 100644 index 0000000000..8f59b06412 --- /dev/null +++ b/apps/backend/scripts/generate-dev-rp-keys.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-console */ +/** + * Generate a fresh EC P-256 RP keyset (signing + encryption) for the MyInfo v5 + * flow against mockpass, writing the public and private JWKS to disk at the + * paths supplied by `MYINFO_V5_RP_JWKS_PUBLIC_PATH` and + * `MYINFO_V5_RP_JWKS_SECRET_PATH`. + * + * Why: we used to ship a static `__fixtures__/keys/dev-rp-*.json` pair so the + * docker-compose dev backend had something to load. Committing private key + * material — even a labelled dev keypair — is a foot-gun, so we generate + * fresh keys on first container start and gitignore the output path instead. + * + * Idempotent: if both files already exist, exits without touching them. That + * way restarting the backend container preserves any in-flight Singpass + * session whose tokens were minted against the existing public JWKS. Wipe + * `.dev-keys/` to force a rotation. + * + * Manual invocation: + * MYINFO_V5_RP_JWKS_PUBLIC_PATH=.dev-keys/rp-v5-public.json \ + * MYINFO_V5_RP_JWKS_SECRET_PATH=.dev-keys/rp-v5-secret.json \ + * pnpm --filter formsg-backend tsx scripts/generate-dev-rp-keys.ts + */ + +import fs from 'fs' +import * as jose from 'jose' +import path from 'path' + +async function main(): Promise { + const publicPath = process.env.MYINFO_V5_RP_JWKS_PUBLIC_PATH + const secretPath = process.env.MYINFO_V5_RP_JWKS_SECRET_PATH + + if (!publicPath || !secretPath) { + console.error( + '[gen-dev-rp-keys] MYINFO_V5_RP_JWKS_PUBLIC_PATH and MYINFO_V5_RP_JWKS_SECRET_PATH must both be set', + ) + process.exit(1) + } + + if (fs.existsSync(publicPath) && fs.existsSync(secretPath)) { + console.log( + `[gen-dev-rp-keys] keys already present at ${publicPath} & ${secretPath} — skipping`, + ) + return + } + + const sig = await jose.generateKeyPair('ES256', { extractable: true }) + const enc = await jose.generateKeyPair('ECDH-ES+A256KW', { + crv: 'P-256', + extractable: true, + }) + const sigPriv = await jose.exportJWK(sig.privateKey) + const sigPub = await jose.exportJWK(sig.publicKey) + const encPriv = await jose.exportJWK(enc.privateKey) + const encPub = await jose.exportJWK(enc.publicKey) + for (const k of [sigPriv, sigPub]) { + k.use = 'sig' + k.alg = 'ES256' + k.kid = 'formsg-v5-sig-1' + } + for (const k of [encPriv, encPub]) { + k.use = 'enc' + k.alg = 'ECDH-ES+A256KW' + k.kid = 'formsg-v5-enc-1' + } + + fs.mkdirSync(path.dirname(publicPath), { recursive: true }) + fs.mkdirSync(path.dirname(secretPath), { recursive: true }) + fs.writeFileSync( + publicPath, + JSON.stringify({ keys: [sigPub, encPub] }, null, 2), + ) + fs.writeFileSync( + secretPath, + JSON.stringify({ keys: [sigPriv, encPriv] }, null, 2), + ) + console.log(`[gen-dev-rp-keys] wrote ${publicPath} and ${secretPath}`) +} + +main().catch((e) => { + console.error('[gen-dev-rp-keys] FAILED:', e) + process.exit(1) +}) diff --git a/apps/backend/scripts/smoke-myinfo-v5.ts b/apps/backend/scripts/smoke-myinfo-v5.ts new file mode 100644 index 0000000000..40951e5d6c --- /dev/null +++ b/apps/backend/scripts/smoke-myinfo-v5.ts @@ -0,0 +1,191 @@ +/* eslint-disable no-console */ +/** + * End-to-end wire-protocol smoke for MyInfo v5 / Singpass Auth v2 against + * mockpass. Pure JOSE + axios — no FormSG app boot, so it can run without + * config env vars. This is the same flow the real `MyInfoV5ServiceClass` + * implements, kept in lockstep deliberately. + * + * Run: + * docker run --rm -d --name mockpass-smoke -p 5156:5156 \ + * --add-host host.docker.internal:host-gateway \ + * -e MOCKPASS_NRIC=S6005038D \ + * -e SHOW_LOGIN_PAGE=false \ + * -e SP_RP_JWKS_ENDPOINT=http://host.docker.internal:5099/jwks \ + * opengovsg/mockpass:4.6.7 + * pnpm tsx scripts/smoke-myinfo-v5.ts + */ + +import axios from 'axios' +import crypto from 'crypto' +import express from 'express' +import http from 'http' +import * as jose from 'jose' + +const ISSUER = process.env.SMOKE_ISSUER ?? 'http://localhost:5156/singpass/v2' +const CLIENT_ID = process.env.SMOKE_CLIENT_ID ?? 'mockClientId' +const REDIRECT_URI = + process.env.SMOKE_REDIRECT_URI ?? 'http://localhost:5000/api/v3/mi/v5/login' +const JWKS_PORT = Number(process.env.SMOKE_JWKS_PORT ?? 5099) + +/** + * Generate a fresh EC P-256 RP keyset (sig + enc) for the smoke run. We + * deliberately do NOT load static fixture files — committing private key + * material, even labelled "dev", is a foot-gun and the smoke flow only + * needs the keypair to be self-consistent for one process lifetime. + */ +async function generateRpJwks(): Promise<{ + publicJwks: jose.JSONWebKeySet + privateJwks: jose.JSONWebKeySet +}> { + const sig = await jose.generateKeyPair('ES256', { extractable: true }) + const enc = await jose.generateKeyPair('ECDH-ES+A256KW', { + crv: 'P-256', + extractable: true, + }) + const sigPriv = await jose.exportJWK(sig.privateKey) + const sigPub = await jose.exportJWK(sig.publicKey) + const encPriv = await jose.exportJWK(enc.privateKey) + const encPub = await jose.exportJWK(enc.publicKey) + for (const k of [sigPriv, sigPub]) { + k.use = 'sig' + k.alg = 'ES256' + k.kid = 'smoke-rp-sig-1' + } + for (const k of [encPriv, encPub]) { + k.use = 'enc' + k.alg = 'ECDH-ES+A256KW' + k.kid = 'smoke-rp-enc-1' + } + return { + publicJwks: { keys: [sigPub, encPub] }, + privateJwks: { keys: [sigPriv, encPriv] }, + } +} + +function base64url(buf: Buffer): string { + return buf + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +function pickJwk(set: jose.JSONWebKeySet, use: 'sig' | 'enc'): jose.JWK { + const k = set.keys.find((kk) => kk.use === use) + if (!k) throw new Error(`no JWK with use=${use}`) + return k +} + +async function main(): Promise { + // 1. Generate a fresh RP keyset and publish its public half for mockpass. + const { publicJwks, privateJwks } = await generateRpJwks() + const app = express() + app.get('/jwks', (_req, res) => res.json(publicJwks)) + const server = http.createServer(app) + await new Promise((r) => server.listen(JWKS_PORT, r)) + console.log(`[smoke] RP JWKS on :${JWKS_PORT}`) + + try { + // 2. Discovery. + const disc = ( + await axios.get(`${ISSUER}/.well-known/openid-configuration`, { + timeout: 5000, + }) + ).data + console.log('[smoke] discovery OK, userinfo:', disc.userinfo_endpoint) + + // 3. PKCE + nonce + state. + const codeVerifier = base64url(crypto.randomBytes(48)) + const codeChallenge = base64url( + crypto.createHash('sha256').update(codeVerifier).digest(), + ) + const nonce = base64url(crypto.randomBytes(32)) + const state = base64url(Buffer.from(JSON.stringify({ formId: 'X' }))) + + // 4. Build auth URL and follow the 302. + const authUrl = new URL(disc.authorization_endpoint) + authUrl.searchParams.set('client_id', CLIENT_ID) + authUrl.searchParams.set('scope', 'openid uinfin name mobileno regadd') + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('redirect_uri', REDIRECT_URI) + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('nonce', nonce) + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + + const authResp = await axios.get(authUrl.toString(), { + maxRedirects: 0, + validateStatus: (s) => s === 302, + }) + const location = authResp.headers['location'] as string + const code = new URL(location).searchParams.get('code') + if (!code) throw new Error(`no code in redirect: ${location}`) + console.log('[smoke] auth code received') + + // 5. private_key_jwt client assertion. + const sigJwk = pickJwk(privateJwks, 'sig') + const signingKey = (await jose.importJWK( + sigJwk, + 'ES256', + )) as jose.KeyLike + const now = Math.floor(Date.now() / 1000) + const clientAssertion = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid: sigJwk.kid! }) + .setIssuer(CLIENT_ID) + .setSubject(CLIENT_ID) + .setAudience(disc.issuer) + .setIssuedAt(now) + .setExpirationTime(now + 60) + .setJti(crypto.randomUUID()) + .sign(signingKey) + + // 6. Token exchange. + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: clientAssertion, + code_verifier: codeVerifier, + }) + const tokenResp = await axios.post(disc.token_endpoint, tokenBody.toString(), { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) + console.log('[smoke] token_type:', tokenResp.data.token_type) + + // 7. Userinfo (Bearer per mockpass; prod uses DPoP). + const userinfoResp = await axios.get(disc.userinfo_endpoint, { + headers: { + authorization: `Bearer ${tokenResp.data.access_token}`, + accept: 'application/jwt', + }, + transformResponse: (raw) => raw, + }) + const jwe = String(userinfoResp.data) + console.log('[smoke] userinfo JWE length:', jwe.length) + + // 8. Decrypt + verify. + const encJwk = pickJwk(privateJwks, 'enc') + const encKey = (await jose.importJWK( + encJwk, + encJwk.alg ?? 'ECDH-ES+A256KW', + )) as jose.KeyLike + const { plaintext } = await jose.compactDecrypt(jwe, encKey) + const jws = new TextDecoder().decode(plaintext) + const idpJwks = jose.createRemoteJWKSet(new URL(disc.jwks_uri)) + const { payload } = await jose.jwtVerify(jws, idpJwks) + console.log('[smoke] userinfo claims:', JSON.stringify(payload, null, 2)) + + console.log('[smoke] OK — v5 wire protocol round-trip succeeded') + } finally { + server.close() + } +} + +main().catch((e) => { + console.error('[smoke] FAILED:', e?.message ?? e) + if (e?.response?.data) console.error('[smoke] response data:', e.response.data) + process.exit(1) +}) diff --git a/apps/backend/src/app/config/features/spcp-myinfo.config.ts b/apps/backend/src/app/config/features/spcp-myinfo.config.ts index 8d726eee96..a1d071ae0c 100644 --- a/apps/backend/src/app/config/features/spcp-myinfo.config.ts +++ b/apps/backend/src/app/config/features/spcp-myinfo.config.ts @@ -47,6 +47,21 @@ type IMyInfoConfig = { myInfoClientId: string myInfoClientSecret: string myInfoJwtSecret: string + // v5 — Singpass Auth API v5 / FAPI 2.0 + // Parameterized so mockpass (http://localhost:5156/singpass/v2) and real + // Singpass (https://stg-id.singpass.gov.sg/auth/v2 or prod equivalent) + // share one code path. + myInfoV5Issuer: string + myInfoV5ClientId: string + myInfoV5RpJwksPublic: string + myInfoV5RpJwksSecret: string + myInfoV5RpJwksPublicPath: string + myInfoV5RpJwksSecretPath: string + /** + * Whether to use RFC 9449 DPoP for token/userinfo. Required by Singpass v5 + * in production; mockpass and dev still use Bearer. + */ + myInfoV5DpopEnabled: boolean } // Config of MyInfo is coupled to that of Singpass @@ -155,6 +170,48 @@ const spcpMyInfoSchema: Schema = { default: null, env: 'MYINFO_JWT_SECRET', }, + myInfoV5Issuer: { + doc: 'Issuer URL for Singpass Auth API v5 / MyInfo v5. Discovery doc is fetched from `${issuer}/.well-known/openid-configuration`. Parameterized so the same code targets mockpass (http://localhost:5156/singpass/v2) and prod Singpass.', + format: String, + default: '', + env: 'MYINFO_V5_ISSUER', + }, + myInfoV5ClientId: { + doc: 'OAuth2 client ID registered with Singpass for the MyInfo v5 / Auth API v5 flow. Distinct from the v3 client id during dual-running.', + format: String, + default: '', + env: 'MYINFO_V5_CLIENT_ID', + }, + myInfoV5RpJwksPublic: { + doc: 'RP public JWKS for the v5 flow (EC keys, sig + enc). Served at /api/v3/mi/v5/.well-known/jwks.json so the IdP can fetch it. Empty default — v5 stays soft-disabled until provisioned.', + format: String, + default: '', + env: 'MYINFO_V5_RP_JWKS_PUBLIC', + }, + myInfoV5RpJwksSecret: { + doc: 'RP private JWKS for the v5 flow (EC keys, sig + enc). Used to sign client_assertion JWTs and to decrypt userinfo JWEs. Empty default — v5 stays soft-disabled until provisioned.', + format: String, + default: '', + env: 'MYINFO_V5_RP_JWKS_SECRET', + }, + myInfoV5RpJwksPublicPath: { + doc: 'Path to RP public JWKS file (alternative to MYINFO_V5_RP_JWKS_PUBLIC).', + format: String, + default: '', + env: 'MYINFO_V5_RP_JWKS_PUBLIC_PATH', + }, + myInfoV5RpJwksSecretPath: { + doc: 'Path to RP private JWKS file (alternative to MYINFO_V5_RP_JWKS_SECRET).', + format: String, + default: '', + env: 'MYINFO_V5_RP_JWKS_SECRET_PATH', + }, + myInfoV5DpopEnabled: { + doc: 'Enable RFC 9449 DPoP for the v5 token + userinfo calls. Required by production Singpass; off by default so dev/mockpass keep working under Bearer auth.', + format: Boolean, + default: false, + env: 'MYINFO_V5_DPOP_ENABLED', + }, spOidcNdiDiscoveryEndpoint: { doc: "NDI's Singpass OIDC Discovery Endpoint", format: String, diff --git a/apps/backend/src/app/modules/form/public-form/public-form.controller.ts b/apps/backend/src/app/modules/form/public-form/public-form.controller.ts index fc44798bb8..a8f42e9dac 100644 --- a/apps/backend/src/app/modules/form/public-form/public-form.controller.ts +++ b/apps/backend/src/app/modules/form/public-form/public-form.controller.ts @@ -16,10 +16,12 @@ import { } from 'formsg-shared/types' import { stripWorkflowEmails } from 'formsg-shared/utils/strip-workflow-emails' import { StatusCodes } from 'http-status-codes' -import { err, ok, okAsync, Result } from 'neverthrow' +import * as jose from 'jose' +import mongoose from 'mongoose' +import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { IPopulatedMultirespondentForm } from '../../../../types' -import { isTest } from '../../../config/config' +import config, { isTest } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import { isMongoError } from '../../../utils/handle-mongo-error' import { createReqMeta, getRequestIp } from '../../../utils/request' @@ -32,6 +34,8 @@ import { MYINFO_AUTH_CODE_COOKIE_OPTIONS, MYINFO_LOGIN_COOKIE_NAME, MYINFO_LOGIN_COOKIE_OPTIONS, + MYINFO_V5_SESSION_COOKIE_NAME, + MYINFO_V5_SESSION_COOKIE_OPTIONS, } from '../../myinfo/myinfo.constants' import { MyInfoService } from '../../myinfo/myinfo.service' import { @@ -39,6 +43,22 @@ import { extractAuthCode, getMyInfoEserviceIdInForm, } from '../../myinfo/myinfo.util' +import { + internalAttrListToV5Scopes, + v5ClaimsToMyInfoData, +} from '../../myinfo/v5/myinfo.v5.adapter' +import { + decryptJwkAtRest, + encryptJwkAtRest, +} from '../../myinfo/v5/myinfo.v5.crypto' +import { + type DpopKeyPair, + generateDpopKeyPair, + importDpopKeyPair, +} from '../../myinfo/v5/myinfo.v5.dpop' +import { MyInfoV5UserinfoError } from '../../myinfo/v5/myinfo.v5.errors' +import { MyInfoV5Service } from '../../myinfo/v5/myinfo.v5.factory' +import getMyInfoV5SessionModel from '../../myinfo/v5/myinfo.v5.session.model' import { SGIDMyInfoData } from '../../sgid/sgid.adapter' import { SGID_CODE_VERIFIER_COOKIE_NAME, @@ -73,6 +93,39 @@ import { mapFormAuthError, mapRouteError } from './public-form.utils' const logger = createLoggerWithLabel(module) +/** + * Create the per-session v5 OAuth scratch row (PKCE verifier + optional DPoP + * private JWK) and return its opaque id. The id is what the cookie carries — + * keeping the private key off the wire. + * + * The DPoP private JWK is encrypted at rest with AES-256-GCM under a key + * derived from `config.sessionSecret`. A DB-only compromise therefore can't + * extract the keypair without also lifting the application secret. + */ +async function persistV5Session({ + codeVerifier, + dpopEnabled, + nonce, +}: { + codeVerifier: string + dpopEnabled: boolean + nonce: string +}): Promise { + const SessionModel = getMyInfoV5SessionModel(mongoose) + let dpopPrivateJwkEnc: string | undefined + if (dpopEnabled) { + const keypair = await generateDpopKeyPair() + const privateJwk = await jose.exportJWK(keypair.privateKey) + dpopPrivateJwkEnc = encryptJwkAtRest(privateJwk, config.sessionSecret) + } + const session = await SessionModel.createSession({ + codeVerifier, + dpopPrivateJwkEnc, + nonce, + }) + return session._id +} + /** * Handler for GET /:formId/publicform endpoint * @returns 200 if the form exists @@ -216,6 +269,121 @@ export const handleGetPublicForm: ControllerHandler< MYINFO_AUTH_CODE_COOKIE_NAME, MYINFO_AUTH_CODE_COOKIE_OPTIONS, ) + + // Dispatch v3 vs v5 on the session cookie. The v5 redirect handler sets + // it when starting a v5 login; v3 forms never set it. The cookie is + // HMAC-signed via cookie-parser with config.sessionSecret, so we read + // from `signedCookies` — cookie-parser surfaces tampered values as + // `false`, which falls through to v3 below. + const sessionIdCookie: unknown = + req.signedCookies[MYINFO_V5_SESSION_COOKIE_NAME] + if (typeof sessionIdCookie === 'string' && sessionIdCookie.length > 0) { + res.clearCookie( + MYINFO_V5_SESSION_COOKIE_NAME, + MYINFO_V5_SESSION_COOKIE_OPTIONS, + ) + const SessionModel = getMyInfoV5SessionModel(mongoose) + const session = await SessionModel.consumeSession(sessionIdCookie) + if (!session) { + logger.error({ + message: 'MyInfo v5 session not found or expired', + meta: logMeta, + }) + return res.json({ + form: publicForm, + errorCodes: [ErrorCode.myInfo], + isIntranetUser, + }) + } + let dpopKeypair: DpopKeyPair | undefined + if (MyInfoV5Service.dpopEnabled) { + if (!session.dpopPrivateJwkEnc) { + logger.error({ + message: + 'MyInfo v5 session missing DPoP private JWK while DPoP is enabled', + meta: logMeta, + }) + return res.json({ + form: publicForm, + errorCodes: [ErrorCode.myInfo], + isIntranetUser, + }) + } + try { + const privateJwk = decryptJwkAtRest( + session.dpopPrivateJwkEnc, + config.sessionSecret, + ) + dpopKeypair = await importDpopKeyPair(privateJwk) + } catch (error) { + logger.error({ + message: + 'Failed to decrypt or import DPoP private JWK from session', + meta: logMeta, + error, + }) + return res.json({ + form: publicForm, + errorCodes: [ErrorCode.myInfo], + isIntranetUser, + }) + } + } + const v5Result = await extractAuthCode(authCodeCookie) + .asyncAndThen((authCode) => + MyInfoV5Service.exchangeCodeForTokens({ + code: authCode, + codeVerifier: session.codeVerifier, + dpopKeypair, + }), + ) + .andThen((tokenResp) => { + // OIDC §3.1.3.7: the id_token's nonce MUST equal the one we sent on + // the authorize request. Hard-fail if either side of the pair is + // missing — without nonce verification we lose the only defense + // against auth-code/id_token replay. (No backward-compat path: + // every session this controller produces persists a nonce, and + // sessions older than 5 min have already been TTL-expired.) + const sessionNonce = + typeof session.nonce === 'string' && session.nonce.length > 0 + ? session.nonce + : undefined + const idToken = tokenResp.id_token + if (!sessionNonce || !idToken) { + return errAsync( + new MyInfoV5UserinfoError( + 'id_token nonce verification not possible — missing session nonce or id_token', + ), + ) + } + return MyInfoV5Service.verifyIdToken({ + idToken, + expectedNonce: sessionNonce, + }).andThen(() => + MyInfoV5Service.fetchUserinfo({ + accessToken: tokenResp.access_token, + dpopKeypair, + }), + ) + }) + .map((claims) => v5ClaimsToMyInfoData(claims)) + if (v5Result.isErr()) { + logger.error({ + message: 'MyInfo v5 login error', + meta: logMeta, + error: v5Result.error, + }) + return res.json({ + form: publicForm, + errorCodes: [ErrorCode.myInfo], + isIntranetUser, + }) + } + myInfoFields = v5Result.value + spcpSession = { userName: myInfoFields.getUinFin() } + break + } + const useEsrvcId = req.growthbook?.isOn(featureFlags.useFormsgEsrvcId) const myInfoFieldsResult = await extractAuthCode(authCodeCookie) .asyncAndThen((authCode) => MyInfoService.retrieveAccessToken(authCode)) @@ -676,7 +844,46 @@ export const _handleFormAuthRedirect: ControllerHandler< featureFlags.useFormsgEsrvcId, ) switch (form.authType) { - case FormAuthType.MyInfo: + case FormAuthType.MyInfo: { + // Flag-gated dispatch to MyInfo v5. We check both the flag AND + // whether v5 is fully provisioned; if either is missing we fall + // back to v3 so a half-configured v5 deploy doesn't break logins. + const useV5 = + Boolean(req.growthbook?.isOn(featureFlags.switchMyinfoV5)) && + MyInfoV5Service.isConfigured + if (useV5) { + return MyInfoV5Service.createRedirectURL({ + formId, + scopes: internalAttrListToV5Scopes(form.getUniqueMyInfoAttrs()), + encodedQuery, + }).andThen(({ redirectURL, codeVerifier, nonce }) => + ResultAsync.fromPromise( + persistV5Session({ + codeVerifier, + dpopEnabled: MyInfoV5Service.dpopEnabled, + nonce, + }).then((sessionId) => { + // Cookie carries an opaque session id; the actual PKCE + // verifier + DPoP private JWK live server-side so the round + // trip survives a load-balancer pod hop. + res.cookie( + MYINFO_V5_SESSION_COOKIE_NAME, + sessionId, + MYINFO_V5_SESSION_COOKIE_OPTIONS, + ) + return redirectURL + }), + (error) => { + logger.error({ + message: 'Failed to save v5 session', + meta: { action: 'handleFormAuthRedirect', formId }, + error, + }) + return new AuthTypeMismatchError(FormAuthType.MyInfo) + }, + ), + ) + } return getMyInfoEserviceIdInForm(form, useFormsgEsrvcId).andThen( ([form, eserviceId]) => MyInfoService.createRedirectURL({ @@ -686,6 +893,7 @@ export const _handleFormAuthRedirect: ControllerHandler< encodedQuery, }), ) + } case FormAuthType.SP: { return validateSpcpForm(form).asyncAndThen((form) => { const target = getRedirectTargetSpcpOidc( diff --git a/apps/backend/src/app/modules/myinfo/myinfo.constants.ts b/apps/backend/src/app/modules/myinfo/myinfo.constants.ts index 350c1acacb..ca09492c55 100644 --- a/apps/backend/src/app/modules/myinfo/myinfo.constants.ts +++ b/apps/backend/src/app/modules/myinfo/myinfo.constants.ts @@ -16,6 +16,20 @@ export const MYINFO_ROUTER_PREFIX = '/mi' */ export const MYINFO_REDIRECT_PATH = '/login' +/** + * Callback path for the Singpass Auth API v5 / MyInfo v5 flow. Registered as a + * separate redirect URI with Singpass so v3 and v5 callbacks can co-exist + * during the flag-gated rollout. + */ +export const MYINFO_V5_REDIRECT_PATH = '/v5/login' + +/** + * Path under MYINFO_ROUTER_PREFIX that serves the RP public JWKS for v5. + * Singpass (and mockpass in dev) fetches this to verify client_assertion + * signatures and to encrypt the userinfo JWE. + */ +export const MYINFO_V5_JWKS_PATH = '/v5/.well-known/jwks.json' + /** * Name of cookie which passes the OAuth authorisation code * from the /myinfo/login endpoint to the public form endpoint. @@ -56,6 +70,30 @@ export const MYINFO_AUTH_CODE_COOKIE_OPTIONS = { maxAge: MYINFO_AUTH_CODE_COOKIE_AGE_MS, } +/** + * Cookie that carries a session id pointing to a `MyInfoV5Session` document. + * That document holds the PKCE code verifier AND (when DPoP is enabled) the + * private JWK for the per-session DPoP keypair. + * + * Why server-side and not cookie-side: a DPoP private JWK is sensitive and + * non-trivial in size; the OAuth round trip can land on a different pod from + * the one that started the login; both make a server-side store the right + * call. See `myinfo.v5.session.model.ts`. + */ +export const MYINFO_V5_SESSION_COOKIE_NAME = 'MyInfoV5Session' + +export const MYINFO_V5_SESSION_COOKIE_OPTIONS = { + httpOnly: true, + sameSite: 'lax' as const, + secure: !config.isDevOrTest, + // HMAC-signed via cookie-parser using `config.sessionSecret`, mirroring + // the `stripeState` precedent. Reads must come from `req.signedCookies`, + // not `req.cookies`, or the value comes back undefined. + signed: true, + // Same TTL as the auth code cookie — both are tied to a single login round-trip. + maxAge: MYINFO_AUTH_CODE_COOKIE_AGE_MS, +} + /** * Message shown on the consent page, which completes the sentence * "This digital service will like to request the following information diff --git a/apps/backend/src/app/modules/myinfo/myinfo.routes.ts b/apps/backend/src/app/modules/myinfo/myinfo.routes.ts index 71b9824ae5..7a86038fd3 100644 --- a/apps/backend/src/app/modules/myinfo/myinfo.routes.ts +++ b/apps/backend/src/app/modules/myinfo/myinfo.routes.ts @@ -2,7 +2,12 @@ import { Router } from 'express' import { authCallbackForwardingMiddleware } from '../auth/auth.middlewares' -import { MYINFO_REDIRECT_PATH } from './myinfo.constants' +import { handleMyInfoV5Login, handleV5Jwks } from './v5/myinfo.v5.controller' +import { + MYINFO_REDIRECT_PATH, + MYINFO_V5_JWKS_PATH, + MYINFO_V5_REDIRECT_PATH, +} from './myinfo.constants' import { handleMyInfoLogin, handleRedirectURLRequest, @@ -26,3 +31,20 @@ MyInfoRouter.get( authCallbackForwardingMiddleware, handleMyInfoLogin, ) + +/** + * v5 — Singpass Auth API v5 / MyInfo v5. + * Mounted alongside v3 so flag-gated traffic can route here without affecting + * v3 forms. Path is registered as a separate redirect URI with Singpass. + */ +MyInfoRouter.get( + MYINFO_V5_REDIRECT_PATH, + authCallbackForwardingMiddleware, + handleMyInfoV5Login, +) + +/** + * Public RP JWKS used by the Singpass IdP to verify our client_assertion and + * encrypt the userinfo JWE. + */ +MyInfoRouter.get(MYINFO_V5_JWKS_PATH, handleV5Jwks) diff --git a/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.adapter.spec.ts b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.adapter.spec.ts new file mode 100644 index 0000000000..019393413b --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.adapter.spec.ts @@ -0,0 +1,135 @@ +import { + internalAttrListToV5Scopes, + v5ClaimsToMyInfoData, + v5ClaimsToPersonResponse, +} from '../myinfo.v5.adapter' +import type { MyInfoV5UserinfoClaims } from '../myinfo.v5.types' + +const baseClaim = (value: string) => ({ + lastupdated: '2024-01-01', + source: '1', + classification: 'C', + value, +}) + +describe('v5 adapter', () => { + describe('v5ClaimsToPersonResponse', () => { + it('passes through a mockpass-shaped flat claim set', () => { + const claims: MyInfoV5UserinfoClaims = { + sub: 'uuid-1', + iss: 'http://localhost:5156/singpass/v2', + aud: 'mockClientId', + iat: 1700000000, + uinfin: baseClaim('S6005038D'), + name: baseClaim('TAN XIAO HUI'), + } + const result = v5ClaimsToPersonResponse(claims) + expect(result.uinFin).toBe('S6005038D') + expect((result.data as Record).name).toEqual( + baseClaim('TAN XIAO HUI'), + ) + // OIDC metadata claims should be stripped from the person payload. + expect((result.data as Record).sub).toBeUndefined() + expect((result.data as Record).iss).toBeUndefined() + }) + + it('unwraps a person_info envelope and merges top-level overrides', () => { + const claims: MyInfoV5UserinfoClaims = { + sub: 'uuid-1', + iss: 'i', + aud: 'a', + iat: 1, + person_info: { + uinfin: baseClaim('S6005038D'), + mobileno: { value: '97324992' }, + }, + // top-level wins so prod additions over the envelope keep working. + mobileno: baseClaim('98765432'), + } + const result = v5ClaimsToPersonResponse(claims) + expect(result.uinFin).toBe('S6005038D') + expect( + (result.data as { mobileno: { value: string } }).mobileno.value, + ).toBe('98765432') + }) + + it('accepts a sub_attributes envelope', () => { + const claims: MyInfoV5UserinfoClaims = { + sub: 'uuid-1', + iss: 'i', + aud: 'a', + iat: 1, + sub_attributes: { + uinfin: baseClaim('S1234567A'), + }, + } + expect(v5ClaimsToPersonResponse(claims).uinFin).toBe('S1234567A') + }) + + it('accepts uinfin as a bare string', () => { + const claims: MyInfoV5UserinfoClaims = { + sub: 'uuid-1', + iss: 'i', + aud: 'a', + iat: 1, + uinfin: 'S7654321B', + } + expect(v5ClaimsToPersonResponse(claims).uinFin).toBe('S7654321B') + }) + + it('returns empty uinFin when claim is missing', () => { + const claims: MyInfoV5UserinfoClaims = { + sub: 'uuid-only', + iss: 'i', + aud: 'a', + iat: 1, + } + expect(v5ClaimsToPersonResponse(claims).uinFin).toBe('') + }) + }) + + describe('v5ClaimsToMyInfoData', () => { + it('returns a MyInfoData that reads `name` via the v3 adapter pipeline', () => { + const claims: MyInfoV5UserinfoClaims = { + sub: 'uuid-1', + iss: 'i', + aud: 'a', + iat: 1, + uinfin: baseClaim('S6005038D'), + name: baseClaim('TAN XIAO HUI'), + } + const data = v5ClaimsToMyInfoData(claims) + expect(data.getUinFin()).toBe('S6005038D') + const nameField = data.getFieldValueForAttr('name' as never) + expect(nameField.fieldValue).toBe('TAN XIAO HUI') + // source=1 means govt-verified — the read-only flag should be set. + expect(nameField.isReadOnly).toBe(true) + }) + }) + + describe('internalAttrListToV5Scopes', () => { + it('always includes openid + uinfin', () => { + const scopes = internalAttrListToV5Scopes([]) + expect(scopes).toEqual(expect.arrayContaining(['openid', 'uinfin'])) + }) + + it('maps known internal attrs to v5 scope names', () => { + const scopes = internalAttrListToV5Scopes(['name', 'mobileno', 'regadd']) + expect(scopes).toEqual( + expect.arrayContaining([ + 'openid', + 'uinfin', + 'name', + 'mobileno', + 'regadd', + ]), + ) + }) + + it('dedupes', () => { + const scopes = internalAttrListToV5Scopes(['name', 'name', 'uinfin']) + const counted = scopes.filter((s) => s === 'name').length + expect(counted).toBe(1) + }) + }) +}) diff --git a/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.crypto.spec.ts b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.crypto.spec.ts new file mode 100644 index 0000000000..95f3d737fa --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.crypto.spec.ts @@ -0,0 +1,116 @@ +import crypto from 'crypto' +import * as jose from 'jose' + +import { + createClientAssertion, + deriveCodeChallenge, + generateNonce, + generatePkceVerifier, + pickJwk, + requireJwk, +} from '../myinfo.v5.crypto' +import { decodeV5State } from '../myinfo.v5.service' + +describe('v5 PKCE helpers', () => { + it('generates verifiers in the RFC 7636 length range', () => { + const v = generatePkceVerifier() + expect(v.length).toBeGreaterThanOrEqual(43) + expect(v.length).toBeLessThanOrEqual(128) + // url-safe base64, no padding + expect(v).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + it('derives a stable S256 challenge that matches the spec', () => { + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const challenge = deriveCodeChallenge(verifier) + // Known-good value from RFC 7636 §4.6 + expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + }) + + it('generates url-safe nonces', () => { + const n = generateNonce() + expect(n).toMatch(/^[A-Za-z0-9_-]+$/) + expect(n.length).toBeGreaterThanOrEqual(32) + }) +}) + +describe('v5 state encode/decode', () => { + // Sanity round-trip — encoded inside the service, decoded by the controller. + it('round-trips formId and encodedQuery', () => { + // We don't export encodeState; reproduce its shape inline. + const state = Buffer.from( + JSON.stringify({ formId: 'abc123', encodedQuery: 'q=1', v: 5 }), + ).toString('base64url') + const result = decodeV5State(state) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap().formId).toBe('abc123') + expect(result._unsafeUnwrap().encodedQuery).toBe('q=1') + }) + + it('rejects malformed state', () => { + expect(decodeV5State('not-base64-json').isErr()).toBe(true) + expect(decodeV5State(Buffer.from('{}').toString('base64url')).isErr()).toBe( + true, + ) + }) +}) + +describe('v5 client assertion', () => { + it('produces a JWT verifiable by the matching public key', async () => { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true, + }) + const privJwk = await jose.exportJWK(privateKey) + privJwk.kid = 'k1' + const importedPriv = (await jose.importJWK( + privJwk, + 'ES256', + )) as jose.KeyLike + + const jwt = await createClientAssertion({ + clientId: 'client-x', + audience: 'https://idp.example/', + signingKey: importedPriv, + signingKid: 'k1', + }) + + const verified = await jose.jwtVerify(jwt, publicKey) + expect(verified.payload.iss).toBe('client-x') + expect(verified.payload.sub).toBe('client-x') + expect(verified.payload.aud).toBe('https://idp.example/') + expect(verified.payload.jti).toBeDefined() + expect(verified.protectedHeader.alg).toBe('ES256') + expect(verified.protectedHeader.kid).toBe('k1') + }) +}) + +describe('pickJwk / requireJwk', () => { + const set: jose.JSONWebKeySet = { + keys: [ + { kty: 'EC', use: 'sig', kid: 's' } as jose.JWK, + { kty: 'EC', use: 'enc', kid: 'e' } as jose.JWK, + ], + } + + it('pickJwk returns first key matching use', () => { + expect(pickJwk(set, 'sig')?.kid).toBe('s') + expect(pickJwk(set, 'enc')?.kid).toBe('e') + }) + + it('pickJwk returns undefined when missing', () => { + expect(pickJwk({ keys: [] }, 'sig')).toBeUndefined() + }) + + it('requireJwk throws when missing (boot-time only)', () => { + expect(() => + requireJwk({ keys: [{ kty: 'EC', use: 'sig' } as jose.JWK] }, 'enc'), + ).toThrow() + }) +}) + +// Ensure crypto.randomBytes works in the test env (sanity). +describe('test env sanity', () => { + it('has crypto.randomBytes', () => { + expect(crypto.randomBytes(4).length).toBe(4) + }) +}) diff --git a/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.dpop.spec.ts b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.dpop.spec.ts new file mode 100644 index 0000000000..e588b15f15 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.dpop.spec.ts @@ -0,0 +1,148 @@ +import * as jose from 'jose' + +import { + computeAccessTokenHash, + createDpopProof, + generateDpopKeyPair, + importDpopKeyPair, +} from '../myinfo.v5.dpop' + +describe('DPoP helpers', () => { + describe('generateDpopKeyPair', () => { + it('produces an ES256 EC keypair with a base64url thumbprint', async () => { + const kp = await generateDpopKeyPair() + expect(kp.publicJwk.kty).toBe('EC') + expect(kp.publicJwk.crv).toBe('P-256') + expect(kp.thumbprint).toMatch(/^[A-Za-z0-9_-]{43}$/) + // Two generations should yield different thumbprints. + const kp2 = await generateDpopKeyPair() + expect(kp.thumbprint).not.toBe(kp2.thumbprint) + }) + }) + + describe('importDpopKeyPair', () => { + it('round-trips: exported private JWK can be re-imported and used', async () => { + const original = await generateDpopKeyPair() + const privateJwk = await jose.exportJWK(original.privateKey) + const rehydrated = await importDpopKeyPair(privateJwk) + + // Thumbprint of the recovered public key matches the original. + expect(rehydrated.thumbprint).toBe(original.thumbprint) + + // Proofs from the rehydrated keypair verify against the original public key. + const proof = await createDpopProof({ + keypair: rehydrated, + htm: 'POST', + htu: 'https://idp.example/token', + }) + const originalPublic = await jose.importJWK(original.publicJwk, 'ES256') + const { payload, protectedHeader } = await jose.jwtVerify( + proof, + originalPublic, + ) + expect(protectedHeader.typ).toBe('dpop+jwt') + expect(payload.htm).toBe('POST') + }) + }) + + describe('createDpopProof', () => { + let keypair: Awaited> + let publicKey: jose.KeyLike + + beforeAll(async () => { + keypair = await generateDpopKeyPair() + publicKey = (await jose.importJWK( + keypair.publicJwk, + 'ES256', + )) as jose.KeyLike + }) + + it('embeds the public JWK in the header and signs verifiably', async () => { + const proof = await createDpopProof({ + keypair, + htm: 'POST', + htu: 'https://idp.example/token', + }) + const { payload, protectedHeader } = await jose.jwtVerify( + proof, + publicKey, + ) + expect(protectedHeader.typ).toBe('dpop+jwt') + expect(protectedHeader.alg).toBe('ES256') + expect(protectedHeader.jwk).toBeDefined() + expect((protectedHeader.jwk as { x: string }).x).toBe( + keypair.publicJwk.x as string, + ) + expect(payload.htm).toBe('POST') + expect(payload.htu).toBe('https://idp.example/token') + expect(typeof payload.jti).toBe('string') + expect(typeof payload.iat).toBe('number') + expect(payload.ath).toBeUndefined() + }) + + it('normalises method to uppercase', async () => { + const proof = await createDpopProof({ + keypair, + htm: 'get', + htu: 'https://idp.example/userinfo', + }) + const { payload } = await jose.jwtVerify(proof, publicKey) + expect(payload.htm).toBe('GET') + }) + + it('strips query and fragment from htu per RFC 9449 §4.2', async () => { + const proof = await createDpopProof({ + keypair, + htm: 'GET', + htu: 'https://idp.example/userinfo?foo=bar#section', + }) + const { payload } = await jose.jwtVerify(proof, publicKey) + expect(payload.htu).toBe('https://idp.example/userinfo') + }) + + it('includes ath claim when an access token is supplied', async () => { + const accessToken = 'mock-access-token-value' + const proof = await createDpopProof({ + keypair, + htm: 'GET', + htu: 'https://idp.example/userinfo', + accessToken, + }) + const { payload } = await jose.jwtVerify(proof, publicKey) + expect(payload.ath).toBe(computeAccessTokenHash(accessToken)) + }) + + it('generates distinct jti per call', async () => { + const a = await createDpopProof({ + keypair, + htm: 'GET', + htu: 'https://idp.example/x', + }) + const b = await createDpopProof({ + keypair, + htm: 'GET', + htu: 'https://idp.example/x', + }) + const ap = await jose.jwtVerify(a, publicKey) + const bp = await jose.jwtVerify(b, publicKey) + expect(ap.payload.jti).not.toBe(bp.payload.jti) + }) + }) + + describe('computeAccessTokenHash', () => { + it('matches the spec example shape (base64url, no padding)', () => { + const h = computeAccessTokenHash('any-token') + expect(h).toMatch(/^[A-Za-z0-9_-]+$/) + // sha256 → 32 bytes → 43 base64url chars + expect(h.length).toBe(43) + }) + + it('is deterministic for the same token', () => { + expect(computeAccessTokenHash('abc')).toBe(computeAccessTokenHash('abc')) + }) + + it('differs for different tokens', () => { + expect(computeAccessTokenHash('a')).not.toBe(computeAccessTokenHash('b')) + }) + }) +}) diff --git a/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.idtoken.spec.ts b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.idtoken.spec.ts new file mode 100644 index 0000000000..5a94b36395 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.idtoken.spec.ts @@ -0,0 +1,213 @@ +/** + * Tests for ID Token nonce verification (OIDC §3.1.3.7). + * + * Covers happy path (matching nonce), mismatch (replay defense), and the + * encrypted/unencrypted ID Token shapes — Singpass returns a JWE-wrapped JWS + * in normal operation but mockpass with `SINGPASS_CLIENT_PROFILE=direct` + * returns a bare JWS. + */ + +import axios from 'axios' +import * as jose from 'jose' + +import { MyInfoV5ServiceClass } from '../myinfo.v5.service' +import type { MyInfoV5RpKeyset } from '../myinfo.v5.types' + +jest.mock('axios') +const mockedAxios = axios as unknown as jest.Mocked + +const ISSUER = 'https://idp.example' +const DISCOVERY = { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/auth`, + token_endpoint: `${ISSUER}/token`, + userinfo_endpoint: `${ISSUER}/userinfo`, + jwks_uri: `${ISSUER}/.well-known/keys`, +} + +type JwksFn = ReturnType +let localJwksLookup: ReturnType | null = null +beforeAll(() => { + jest + .spyOn(jose, 'createRemoteJWKSet') + .mockImplementation( + () => + ((...args: Parameters) => + localJwksLookup!(...args)) as unknown as JwksFn, + ) +}) +afterAll(() => jest.restoreAllMocks()) + +beforeEach(() => { + mockedAxios.get.mockReset() + mockedAxios.get.mockImplementation(async (url: string) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return { data: DISCOVERY } as unknown as Awaited< + ReturnType + > + } + throw new Error(`unmocked GET ${url}`) + }) +}) + +async function makeRpKeyset(): Promise { + const sig = await jose.generateKeyPair('ES256', { extractable: true }) + const enc = await jose.generateKeyPair('ECDH-ES+A256KW', { + crv: 'P-256', + extractable: true, + }) + const sigPriv = await jose.exportJWK(sig.privateKey) + const sigPub = await jose.exportJWK(sig.publicKey) + const encPriv = await jose.exportJWK(enc.privateKey) + const encPub = await jose.exportJWK(enc.publicKey) + ;[sigPriv, sigPub].forEach((k) => { + k.use = 'sig' + k.alg = 'ES256' + k.kid = 'sig-1' + }) + ;[encPriv, encPub].forEach((k) => { + k.use = 'enc' + k.alg = 'ECDH-ES+A256KW' + k.kid = 'enc-1' + }) + return { + publicJwks: { keys: [sigPub, encPub] }, + privateJwks: { keys: [sigPriv, encPriv] }, + } +} + +async function buildJws( + payload: Record, +): Promise<{ jws: string; idpPublic: jose.KeyLike }> { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true, + }) + const jws = await new jose.SignJWT(payload) + .setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid: 'idp-sig-1' }) + .sign(privateKey) + return { jws, idpPublic: publicKey as jose.KeyLike } +} + +async function wrapJwsInJwe(jws: string, encJwk: jose.JWK): Promise { + const encKey = await jose.importJWK(encJwk, encJwk.alg ?? 'ECDH-ES+A256KW') + return await new jose.CompactEncrypt(new TextEncoder().encode(jws)) + .setProtectedHeader({ + alg: 'ECDH-ES+A256KW', + enc: 'A256GCM', + typ: 'JWT', + cty: 'JWT', + kid: 'enc-1', + }) + .encrypt(encKey) +} + +async function installIdpJwks(pub: jose.KeyLike): Promise { + const jwk = await jose.exportJWK(pub) + jwk.use = 'sig' + jwk.alg = 'ES256' + jwk.kid = 'idp-sig-1' + localJwksLookup = jose.createLocalJWKSet({ keys: [jwk] }) +} + +describe('MyInfoV5ServiceClass.verifyIdToken', () => { + let svc: MyInfoV5ServiceClass + let rpKeyset: MyInfoV5RpKeyset + + beforeEach(async () => { + rpKeyset = await makeRpKeyset() + svc = new MyInfoV5ServiceClass({ + issuer: ISSUER, + clientId: 'client-x', + redirectUri: 'https://app.example/cb', + rpKeyset, + dpopEnabled: false, + }) + }) + + it('accepts a matching nonce in a JWE-wrapped id_token (prod shape)', async () => { + const { jws, idpPublic } = await buildJws({ + sub: 'u', + iss: 'https://idp.example', + aud: 'client-x', + iat: 1, + nonce: 'good-nonce', + }) + await installIdpJwks(idpPublic) + const encJwk = rpKeyset.publicJwks.keys.find((k) => k.use === 'enc')! + const idToken = await wrapJwsInJwe(jws, encJwk) + + const result = await svc.verifyIdToken({ + idToken, + expectedNonce: 'good-nonce', + }) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap().nonce).toBe('good-nonce') + }) + + it('accepts a matching nonce in a bare JWS id_token (mockpass direct profile)', async () => { + const { jws, idpPublic } = await buildJws({ + sub: 'u', + iss: 'https://idp.example', + aud: 'client-x', + iat: 1, + nonce: 'good-nonce', + }) + await installIdpJwks(idpPublic) + + const result = await svc.verifyIdToken({ + idToken: jws, + expectedNonce: 'good-nonce', + }) + expect(result.isOk()).toBe(true) + }) + + it('rejects a mismatched nonce', async () => { + const { jws, idpPublic } = await buildJws({ + sub: 'u', + iss: 'https://idp.example', + aud: 'client-x', + iat: 1, + nonce: 'attacker-nonce', + }) + await installIdpJwks(idpPublic) + + const result = await svc.verifyIdToken({ + idToken: jws, + expectedNonce: 'real-nonce', + }) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr().message).toMatch(/nonce mismatch/i) + }) + + it('rejects a malformed id_token (wrong segment count)', async () => { + await installIdpJwks( + (await jose.generateKeyPair('ES256', { extractable: true })).publicKey, + ) + const result = await svc.verifyIdToken({ + idToken: 'a.b', // 2 segments — not JWS (3) and not JWE (5) + expectedNonce: 'whatever', + }) + expect(result.isErr()).toBe(true) + }) + + it('rejects a JWS signed by a different key (signature mismatch)', async () => { + const { jws } = await buildJws({ + sub: 'u', + iss: 'https://idp.example', + aud: 'client-x', + iat: 1, + nonce: 'good-nonce', + }) + // Install a DIFFERENT signing key — verification should fail. + const { publicKey: otherPub } = await jose.generateKeyPair('ES256', { + extractable: true, + }) + await installIdpJwks(otherPub as jose.KeyLike) + + const result = await svc.verifyIdToken({ + idToken: jws, + expectedNonce: 'good-nonce', + }) + expect(result.isErr()).toBe(true) + }) +}) diff --git a/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.service.spec.ts b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.service.spec.ts new file mode 100644 index 0000000000..4dda383456 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/__tests__/myinfo.v5.service.spec.ts @@ -0,0 +1,273 @@ +/** + * Service-level tests focused on the boundary between MyInfoV5ServiceClass + * and the OIDC wire. Axios is mocked so we can assert request shape + * (DPoP header presence, Authorization scheme) without standing up mockpass. + * + * The full end-to-end happy path is covered by scripts/smoke-myinfo-v5.ts. + */ + +import axios, { type AxiosRequestConfig } from 'axios' +import * as jose from 'jose' + +import { generateDpopKeyPair } from '../myinfo.v5.dpop' +import { MyInfoV5ServiceClass } from '../myinfo.v5.service' +import type { MyInfoV5RpKeyset } from '../myinfo.v5.types' + +jest.mock('axios') +const mockedAxios = axios as unknown as jest.Mocked + +// `jose.createRemoteJWKSet` returns a function that fetches the JWKS via the +// global `fetch`. In jest we don't want that — swap it for a local JWKS lookup +// driven by the IdP's public key the test sets up. +type JwksFn = ReturnType +let localJwksLookup: ReturnType | null = null +beforeAll(() => { + jest + .spyOn(jose, 'createRemoteJWKSet') + .mockImplementation( + () => + ((...args: Parameters) => + localJwksLookup!(...args)) as unknown as JwksFn, + ) +}) +afterAll(() => { + jest.restoreAllMocks() +}) + +const ISSUER = 'https://idp.example' +const DISCOVERY = { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/auth`, + token_endpoint: `${ISSUER}/token`, + userinfo_endpoint: `${ISSUER}/userinfo`, + jwks_uri: `${ISSUER}/.well-known/keys`, +} + +async function makeRpKeyset(): Promise { + const sig = await jose.generateKeyPair('ES256', { extractable: true }) + const enc = await jose.generateKeyPair('ECDH-ES+A256KW', { + crv: 'P-256', + extractable: true, + }) + const sigPriv = await jose.exportJWK(sig.privateKey) + const sigPub = await jose.exportJWK(sig.publicKey) + const encPriv = await jose.exportJWK(enc.privateKey) + const encPub = await jose.exportJWK(enc.publicKey) + ;[sigPriv, sigPub].forEach((k) => { + k.use = 'sig' + k.alg = 'ES256' + k.kid = 'sig-1' + }) + ;[encPriv, encPub].forEach((k) => { + k.use = 'enc' + k.alg = 'ECDH-ES+A256KW' + k.kid = 'enc-1' + }) + return { + publicJwks: { keys: [sigPub, encPub] }, + privateJwks: { keys: [sigPriv, encPriv] }, + } +} + +async function makeService(opts: { dpopEnabled: boolean }) { + return new MyInfoV5ServiceClass({ + issuer: ISSUER, + clientId: 'client-x', + redirectUri: 'https://app.example/cb', + rpKeyset: await makeRpKeyset(), + dpopEnabled: opts.dpopEnabled, + }) +} + +describe('MyInfoV5ServiceClass — DPoP vs Bearer', () => { + beforeEach(() => { + mockedAxios.get.mockReset() + mockedAxios.post.mockReset() + mockedAxios.get.mockImplementation(async (url: string) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return { data: DISCOVERY } as unknown as Awaited< + ReturnType + > + } + throw new Error(`unmocked GET ${url}`) + }) + }) + + describe('exchangeCodeForTokens', () => { + it('sends no DPoP header when dpopEnabled=false', async () => { + const svc = await makeService({ dpopEnabled: false }) + mockedAxios.post.mockResolvedValueOnce({ + data: { access_token: 'tok', token_type: 'Bearer' }, + } as unknown as Awaited>) + + const result = await svc.exchangeCodeForTokens({ + code: 'abc', + codeVerifier: 'verifier', + }) + expect(result.isOk()).toBe(true) + expect(mockedAxios.post).toHaveBeenCalledTimes(1) + const [, , cfg] = mockedAxios.post.mock.calls[0] as [ + string, + string, + AxiosRequestConfig, + ] + expect(cfg.headers?.dpop).toBeUndefined() + }) + + it('attaches a DPoP proof bound to the supplied keypair when dpopEnabled=true', async () => { + const svc = await makeService({ dpopEnabled: true }) + const kp = await generateDpopKeyPair() + mockedAxios.post.mockResolvedValueOnce({ + data: { access_token: 'tok', token_type: 'DPoP' }, + } as unknown as Awaited>) + + const result = await svc.exchangeCodeForTokens({ + code: 'abc', + codeVerifier: 'verifier', + dpopKeypair: kp, + }) + expect(result.isOk()).toBe(true) + const [, , cfg] = mockedAxios.post.mock.calls[0] as [ + string, + string, + AxiosRequestConfig, + ] + const dpopHeader = cfg.headers?.dpop as string + expect(dpopHeader).toBeDefined() + + // The proof must be a JWT verifiable by the supplied keypair, with + // htm=POST and htu equal to the token endpoint. + const publicKey = await jose.importJWK(kp.publicJwk, 'ES256') + const { payload, protectedHeader } = await jose.jwtVerify( + dpopHeader, + publicKey, + ) + expect(protectedHeader.typ).toBe('dpop+jwt') + expect(payload.htm).toBe('POST') + expect(payload.htu).toBe(DISCOVERY.token_endpoint) + expect(payload.ath).toBeUndefined() + }) + + it('returns an error when DPoP is enabled but no keypair is supplied', async () => { + const svc = await makeService({ dpopEnabled: true }) + const result = await svc.exchangeCodeForTokens({ + code: 'abc', + codeVerifier: 'verifier', + }) + expect(result.isErr()).toBe(true) + expect(mockedAxios.post).not.toHaveBeenCalled() + }) + }) + + describe('fetchUserinfo', () => { + // Build a userinfo JWE the service can decrypt. Uses the public enc key + // from the service's RP keyset, signed with a fake IDP key. + async function buildUserinfoJwe( + rpEncJwk: jose.JWK, + claims: Record, + ): Promise<{ jwe: string; idpPublic: jose.KeyLike }> { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true, + }) + const signed = await new jose.SignJWT(claims) + .setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid: 'idp-sig-1' }) + .sign(privateKey) + const encKey = await jose.importJWK( + rpEncJwk, + rpEncJwk.alg ?? 'ECDH-ES+A256KW', + ) + const jwe = await new jose.CompactEncrypt( + new TextEncoder().encode(signed), + ) + .setProtectedHeader({ + alg: 'ECDH-ES+A256KW', + enc: 'A256GCM', + typ: 'JWT', + cty: 'JWT', + kid: 'enc-1', + }) + .encrypt(encKey) + return { jwe, idpPublic: publicKey as jose.KeyLike } + } + + async function installIdpJwks(idpPublic: jose.KeyLike): Promise { + const pubJwk = await jose.exportJWK(idpPublic) + pubJwk.use = 'sig' + pubJwk.alg = 'ES256' + pubJwk.kid = 'idp-sig-1' + localJwksLookup = jose.createLocalJWKSet({ keys: [pubJwk] }) + } + + async function setupUserinfoMocks(svc: MyInfoV5ServiceClass): Promise<{ + jwe: string + getCall: () => AxiosRequestConfig | undefined + }> { + const publicJwks = svc.getPublicJwks() + const encKey = publicJwks.keys.find((k) => k.use === 'enc')! + const { jwe, idpPublic } = await buildUserinfoJwe(encKey, { + sub: 'u', + iss: ISSUER, + aud: 'client-x', + iat: 1, + name: { value: 'TAN' }, + }) + await installIdpJwks(idpPublic) + let userinfoCall: AxiosRequestConfig | undefined + mockedAxios.get.mockImplementation( + async (url: string, cfg?: AxiosRequestConfig) => { + if (url.endsWith('/.well-known/openid-configuration')) { + return { data: DISCOVERY } as unknown as Awaited< + ReturnType + > + } + if (url === DISCOVERY.userinfo_endpoint) { + userinfoCall = cfg + return { data: jwe } as unknown as Awaited< + ReturnType + > + } + throw new Error(`unmocked GET ${url}`) + }, + ) + return { jwe, getCall: () => userinfoCall } + } + + it('sends Authorization: Bearer when dpopEnabled=false', async () => { + const svc = await makeService({ dpopEnabled: false }) + const { getCall } = await setupUserinfoMocks(svc) + + const result = await svc.fetchUserinfo({ accessToken: 'tok-bearer' }) + expect(result.isOk()).toBe(true) + const userinfoCall = getCall() + expect(userinfoCall?.headers?.authorization).toBe('Bearer tok-bearer') + expect(userinfoCall?.headers?.dpop).toBeUndefined() + }) + + it('sends Authorization: DPoP + DPoP proof with ath when dpopEnabled=true', async () => { + const svc = await makeService({ dpopEnabled: true }) + const kp = await generateDpopKeyPair() + const { getCall } = await setupUserinfoMocks(svc) + + const result = await svc.fetchUserinfo({ + accessToken: 'tok-dpop', + dpopKeypair: kp, + }) + expect(result.isOk()).toBe(true) + const userinfoCall = getCall() + expect(userinfoCall?.headers?.authorization).toBe('DPoP tok-dpop') + + const dpopHeader = userinfoCall?.headers?.dpop as string + const publicKey = await jose.importJWK(kp.publicJwk, 'ES256') + const { payload } = await jose.jwtVerify(dpopHeader, publicKey) + expect(payload.htm).toBe('GET') + expect(payload.htu).toBe(DISCOVERY.userinfo_endpoint) + expect(payload.ath).toBeDefined() + }) + + it('returns an error when DPoP is enabled but keypair missing', async () => { + const svc = await makeService({ dpopEnabled: true }) + const result = await svc.fetchUserinfo({ accessToken: 'x' }) + expect(result.isErr()).toBe(true) + }) + }) +}) diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.adapter.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.adapter.ts new file mode 100644 index 0000000000..6eebe6b3e6 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.adapter.ts @@ -0,0 +1,154 @@ +import { + IPerson, + IPersonResponse, + MyInfoAttribute as ExternalAttr, +} from '@opengovsg/myinfo-gov-client' + +import { MyInfoData } from '../myinfo.adapter' + +import type { MyInfoV5UserinfoClaims } from './myinfo.v5.types' + +/** + * Adapt a v5 userinfo claim set to the v3 `IPersonResponse` shape that + * `MyInfoData` already understands. Keeps the downstream pipeline (formatters, + * `getFieldValueForAttr`, hash computation) untouched. + * + * Two shapes need handling: + * + * 1) Mockpass / Singpass simple shape: + * { sub, iss, aud, iat, uinfin: {value,...}, name: {value,...}, ... } + * Each scope claim sits at the top level using the v3 attribute shape + * (`{ value, lastupdated, source, classification }`). Direct pass-through. + * + * 2) Singpass v5 production "person_info" envelope: + * { sub, iss, ..., person_info: { uinfin: {...}, name: {...}, ... } } + * or with `sub_attributes`. We unwrap into the same flat dict. + * + * Anything we don't recognise is kept on `data` as-is so the v3 adapter can + * still try to format it. Unknown fields are tolerated, not erroneously + * rejected — Singpass v5 may add fields between releases. + */ +export function v5ClaimsToPersonResponse( + claims: MyInfoV5UserinfoClaims, +): IPersonResponse { + const merged: Record = {} + + // Unwrap an optional envelope. + const envelope = + (isObject(claims.person_info) && (claims.person_info as object)) || + (isObject(claims['sub_attributes']) && + (claims['sub_attributes'] as object)) || + null + + if (envelope) Object.assign(merged, envelope) + + // Top-level claims override / extend the envelope so mockpass-style flat + // payloads still win. + for (const [k, v] of Object.entries(claims)) { + if ( + k === 'sub' || + k === 'iss' || + k === 'aud' || + k === 'iat' || + k === 'nonce' + ) + continue + if (k === 'person_info' || k === 'sub_attributes') continue + merged[k] = v + } + + const uinFin = extractUinFin(merged) ?? extractUinFinFromSub(claims.sub) + return { + uinFin: uinFin ?? '', + // The v3 IPerson type is a strongly-typed open record; the v3 adapter + // reads keys defensively via _formatFieldValue, so an unknown subset is + // safe at the type boundary. + data: merged as unknown as IPerson, + } +} + +function extractUinFin(claims: Record): string | undefined { + // v5 may expose uinfin as a typed claim {value,...} or as a bare string. + const raw = claims['uinfin'] ?? claims['uinFin'] + if (typeof raw === 'string') return raw + if (isObject(raw) && typeof (raw as { value?: unknown }).value === 'string') { + return (raw as { value: string }).value + } + return undefined +} + +function extractUinFinFromSub(sub: unknown): string | undefined { + // Singpass `sub` is a UUID in v5. NRIC is no longer in `sub`. Returning + // undefined here is the correct fallback when `uinfin` claim is absent — + // the form will fail closed. + void sub + return undefined +} + +function isObject(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x) +} + +/** + * Convenience factory: claims → `MyInfoData` that the rest of FormSG already + * uses for prefill, hashing, and read-only enforcement. + */ +export function v5ClaimsToMyInfoData( + claims: MyInfoV5UserinfoClaims, +): MyInfoData { + return new MyInfoData(v5ClaimsToPersonResponse(claims)) +} + +/** + * Translate an internal FormSG attribute set into the OIDC scope set we need + * to request from Singpass v5. + * + * Singpass v5 scopes are camelCase MyInfo attribute names (e.g. `mobileno`, + * `regadd`), matching the existing `ExternalAttr` values. We always request + * `openid` and `uinfin` (we need the NRIC for hashing / identifying the user). + */ +export function internalAttrListToV5Scopes(attrs: Iterable): string[] { + const scopes = new Set(['openid', 'uinfin']) + for (const attr of attrs) { + const scope = internalAttrToV5Scope(attr) + if (scope) scopes.add(scope) + } + return Array.from(scopes) +} + +function internalAttrToV5Scope(attr: string): string | undefined { + // For attributes whose internal name already matches the v5 scope, this is a + // noop. We piggyback on the v3 mapping where it exists so we don't drift. + switch (attr) { + case 'name': + return ExternalAttr.Name + case 'mobileno': + return ExternalAttr.MobileNo + case 'regadd': + return ExternalAttr.RegisteredAddress + case 'dob': + return ExternalAttr.DateOfBirth + case 'sex': + return ExternalAttr.Sex + case 'race': + return ExternalAttr.Race + case 'nationality': + return ExternalAttr.Nationality + case 'birthcountry': + return ExternalAttr.BirthCountry + case 'residentialstatus': + return ExternalAttr.ResidentialStatus + case 'employment': + return ExternalAttr.Employment + case 'occupation': + return ExternalAttr.Occupation + case 'marital': + return ExternalAttr.MaritalStatus + case 'passportnumber': + return ExternalAttr.PassportNumber + // Fallback — pass internal name through; mockpass + prod v5 accept + // the v3 attribute name as-is for most claims. + default: + return attr + } +} diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.controller.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.controller.ts new file mode 100644 index 0000000000..4bf4425383 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.controller.ts @@ -0,0 +1,147 @@ +import { celebrate, Joi, Segments } from 'celebrate' +import { StatusCodes } from 'http-status-codes' + +import { Environment } from '../../../../types' +import config from '../../../config/config' +import { createLoggerWithLabel } from '../../../config/logger' +import { ControllerHandler } from '../../core/core.types' +import { + MYINFO_AUTH_CODE_COOKIE_NAME, + MYINFO_AUTH_CODE_COOKIE_OPTIONS, +} from '../myinfo.constants' +import { + MyInfoAuthCodeCookiePayload, + MyInfoAuthCodeCookieState, + MyInfoAuthCodeSuccessPayload, +} from '../myinfo.types' + +import { MyInfoV5Service } from './myinfo.v5.factory' +import { decodeV5State } from './myinfo.v5.service' + +const logger = createLoggerWithLabel(module) + +/** + * GET /api/v3/mi/v5/.well-known/jwks.json + * + * Serves the RP public JWKS so the Singpass IdP can verify our + * client_assertion signatures and encrypt the userinfo JWE addressed to us. + * + * Mockpass fetches this via SP_RP_JWKS_ENDPOINT; prod Singpass requires it + * to be reachable from the public internet at the registered URL. + */ +export const handleV5Jwks: ControllerHandler = (_req, res) => { + res.set('cache-control', 'public, max-age=300') + return res.status(StatusCodes.OK).json(MyInfoV5Service.getPublicJwks()) +} + +/** + * Validation middleware for the v5 OAuth callback. The query shape mirrors + * v3 (code + state) but we also accept the standard OIDC error response so + * we can render a sensible error page rather than 500. + */ +const validateV5Login = celebrate({ + [Segments.QUERY]: Joi.alternatives().try( + Joi.object() + .keys({ + code: Joi.string().required(), + state: Joi.string().required(), + }) + .unknown(true), + Joi.object() + .keys({ + error: Joi.string().required(), + 'error-description': Joi.string(), + state: Joi.string().required(), + }) + .unknown(true), + ), +}) + +type V5LoginQuery = + | { code: string; state: string } + | { error: string; 'error-description'?: string; state: string } + +/** + * GET /api/v3/mi/v5/login + * + * Receives the OAuth code from Singpass, validates the state, and forwards + * the user back to the original form. The actual token exchange happens + * lazily — we drop the code into the standard MyInfoAuthCode cookie so the + * shared form-view path picks it up. + */ +export const loginToMyInfoV5: ControllerHandler< + unknown, + unknown, + unknown, + V5LoginQuery +> = (req, res) => { + const { state } = req.query + const logMeta = { action: 'loginToMyInfoV5', state } + + const parsed = decodeV5State(state) + if (parsed.isErr()) { + logger.error({ + message: 'Invalid MyInfo v5 state', + meta: logMeta, + error: parsed.error, + }) + return res.sendStatus(StatusCodes.BAD_REQUEST) + } + const { formId, encodedQuery } = parsed.value + + const redirectRaw = + process.env.NODE_ENV === Environment.Dev + ? `${config.app.feAppUrl}/${formId}` + : `/${formId}` + + let redirectDestination = redirectRaw + if (encodedQuery) { + try { + redirectDestination = `${redirectRaw}?${Buffer.from( + encodedQuery, + 'base64', + ).toString('utf8')}` + } catch { + // Fall back to default if encoded query is malformed. + } + } + + if ('error' in req.query) { + logger.error({ + message: 'MyInfo v5 returned error from consent flow', + meta: { + ...logMeta, + error: req.query.error, + errorDescription: req.query['error-description'], + }, + }) + const errorPayload: MyInfoAuthCodeCookiePayload = { + state: MyInfoAuthCodeCookieState.Error, + } + res.cookie( + MYINFO_AUTH_CODE_COOKIE_NAME, + errorPayload, + MYINFO_AUTH_CODE_COOKIE_OPTIONS, + ) + return res.redirect(redirectDestination) + } + + // The PKCE verifier stays in its cookie. The form-view handler reads BOTH + // the auth-code cookie and the PKCE cookie when finalising the v5 flow. + // We don't clear the PKCE cookie here — the form-view endpoint does it. + const cookiePayload: MyInfoAuthCodeSuccessPayload = { + authCode: req.query.code, + state: MyInfoAuthCodeCookieState.Success, + } + res.cookie( + MYINFO_AUTH_CODE_COOKIE_NAME, + cookiePayload, + MYINFO_AUTH_CODE_COOKIE_OPTIONS, + ) + return res.redirect(redirectDestination) +} + +export const handleMyInfoV5Login = [ + validateV5Login, + loginToMyInfoV5, +] as ControllerHandler[] diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.crypto.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.crypto.ts new file mode 100644 index 0000000000..0fbcb17b5d --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.crypto.ts @@ -0,0 +1,174 @@ +import crypto from 'crypto' +import type { JSONWebKeySet, JWK, KeyLike } from 'jose' +import * as jose from 'jose' + +/** + * Generate a PKCE code_verifier (RFC 7636 §4.1) of 64 url-safe-base64 chars. + * 43-128 chars is the spec range; we pick 64 for headroom. + */ +export function generatePkceVerifier(): string { + return base64urlEncode(crypto.randomBytes(48)) +} + +/** + * Derive the S256 code_challenge from a verifier per RFC 7636 §4.2. + */ +export function deriveCodeChallenge(verifier: string): string { + return base64urlEncode(crypto.createHash('sha256').update(verifier).digest()) +} + +/** + * Cryptographically random state/nonce. + */ +export function generateNonce(): string { + return base64urlEncode(crypto.randomBytes(32)) +} + +function base64urlEncode(buf: Buffer): string { + return buf + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +/** + * Pick a key from a JWKS by `use` (sig|enc). Returns undefined if not found — + * callers should surface this as a config error. + */ +export function pickJwk( + jwks: JSONWebKeySet, + use: 'sig' | 'enc', +): JWK | undefined { + return jwks.keys.find((k) => k.use === use) +} + +/** Strict pickJwk that throws — only for synchronous boot-time code paths. */ +export function requireJwk(jwks: JSONWebKeySet, use: 'sig' | 'enc'): JWK { + const found = pickJwk(jwks, use) + if (!found) { + // eslint-disable-next-line typesafe/no-throw-sync-func + throw new Error(`No JWK with use=${use} in keyset`) + } + return found +} + +/** + * Build the JWT used as `client_assertion` for `private_key_jwt` + * token-endpoint authentication (RFC 7523 §2.2). + */ +export async function createClientAssertion({ + clientId, + audience, + signingKey, + signingKid, + signingAlg = 'ES256', + lifetimeSeconds = 60, +}: { + clientId: string + /** OIDC token endpoint URL OR issuer — Singpass v5 uses issuer. */ + audience: string + signingKey: KeyLike + signingKid: string + signingAlg?: string + lifetimeSeconds?: number +}): Promise { + const now = Math.floor(Date.now() / 1000) + return new jose.SignJWT({}) + .setProtectedHeader({ alg: signingAlg, typ: 'JWT', kid: signingKid }) + .setIssuer(clientId) + .setSubject(clientId) + .setAudience(audience) + .setIssuedAt(now) + .setExpirationTime(now + lifetimeSeconds) + .setJti(crypto.randomUUID()) + .sign(signingKey) +} + +/** + * Encrypted-at-rest envelope for a JWK. AES-256-GCM with a key derived from + * the supplied secret via PBKDF2-SHA256. Fresh per-record salt + IV so two + * identical JWKs never encrypt to the same ciphertext. + * + * Envelope: `v1....` — five base64url segments, + * dot-delimited. The leading version tag lets us rotate algorithms later + * without ambiguity. PBKDF2 iteration count is conservative for a high-entropy + * input like `sessionSecret`; a stronger KDF isn't required here, but PBKDF2 + * is what the surrounding code already uses for key stretching. + */ +const JWK_ENCRYPT_VERSION = 'v1' +const JWK_ENCRYPT_SALT_BYTES = 16 +const JWK_ENCRYPT_IV_BYTES = 12 +const JWK_ENCRYPT_KEY_BYTES = 32 +const JWK_ENCRYPT_PBKDF2_ITERATIONS = 100_000 +const JWK_ENCRYPT_PBKDF2_DIGEST = 'sha256' + +function deriveJwkEncryptionKey(secret: string, salt: Buffer): Buffer { + return crypto.pbkdf2Sync( + secret, + salt, + JWK_ENCRYPT_PBKDF2_ITERATIONS, + JWK_ENCRYPT_KEY_BYTES, + JWK_ENCRYPT_PBKDF2_DIGEST, + ) +} + +export function encryptJwkAtRest(jwk: JWK, secret: string): string { + const salt = crypto.randomBytes(JWK_ENCRYPT_SALT_BYTES) + const iv = crypto.randomBytes(JWK_ENCRYPT_IV_BYTES) + const key = deriveJwkEncryptionKey(secret, salt) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const plaintext = Buffer.from(JSON.stringify(jwk), 'utf8') + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + return [ + JWK_ENCRYPT_VERSION, + salt.toString('base64url'), + iv.toString('base64url'), + tag.toString('base64url'), + ciphertext.toString('base64url'), + ].join('.') +} + +export function decryptJwkAtRest(envelope: string, secret: string): JWK { + const parts = envelope.split('.') + if (parts.length !== 5 || parts[0] !== JWK_ENCRYPT_VERSION) { + // eslint-disable-next-line typesafe/no-throw-sync-func + throw new Error('Unsupported encrypted JWK envelope') + } + const [, saltB64, ivB64, tagB64, ciphertextB64] = parts + const salt = Buffer.from(saltB64, 'base64url') + const iv = Buffer.from(ivB64, 'base64url') + const tag = Buffer.from(tagB64, 'base64url') + const ciphertext = Buffer.from(ciphertextB64, 'base64url') + const key = deriveJwkEncryptionKey(secret, salt) + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]) + return JSON.parse(plaintext.toString('utf8')) as JWK +} + +/** + * Defense-in-depth shape check before handing a persisted private JWK to + * `jose.importJWK`. Catches DB rows that have been tampered with into + * pointing at unexpected key material (different curve, different kty, or + * a public-only JWK missing `d`). + */ +export function assertEcP256PrivateJwk(jwk: unknown): asserts jwk is JWK { + const j = jwk as Partial | null + if ( + !j || + typeof j !== 'object' || + j.kty !== 'EC' || + j.crv !== 'P-256' || + typeof j.x !== 'string' || + typeof j.y !== 'string' || + typeof j.d !== 'string' + ) { + // eslint-disable-next-line typesafe/no-throw-sync-func + throw new Error('Expected EC P-256 private JWK') + } +} diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.dpop.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.dpop.ts new file mode 100644 index 0000000000..9dd73a1d10 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.dpop.ts @@ -0,0 +1,149 @@ +/** + * RFC 9449 DPoP (Demonstrating Proof of Possession) support for the MyInfo v5 + * / Singpass Auth API v5 flow. + * + * DPoP binds an access token to a public key held by the client. Each protected + * request (token exchange, userinfo, ...) carries: + * - a `DPoP` header with a JWT signed by the client's private key + * - the proof's JWT header includes `jwk` so the AS/RS can verify it + * - the JWT body asserts `htm` (HTTP method), `htu` (full URL minus query/fragment), + * `jti` (unique), `iat`, and on resource calls `ath` (base64url-sha256 of access token) + * + * Production Singpass v5 mandates DPoP; mockpass uses Bearer. We control which + * scheme is used via `MyInfoV5ServiceClass` config so the same code targets both. + * + * Key management: + * - One EC P-256 keypair per process. Generated at module load. The tokens we + * receive from Singpass are bound to this key's thumbprint — restarting the + * process invalidates any in-flight access tokens (max 2 min window, fine). + * - For multi-instance prod, every replica generates its own keypair. The IdP + * doesn't need to know about the keys ahead of time (DPoP is "ephemeral"). + * What DOES need attention before scaling: if a token is issued by replica A + * and the user's next request lands on replica B, the DPoP-bound token won't + * validate. The MyInfo auth code → access token → userinfo round trip + * happens within one HTTP request (form-view path), so this is OK today. + * When that changes, persist the keypair to a shared store. + */ + +import crypto from 'crypto' +import type { JWK } from 'jose' +import * as jose from 'jose' + +import { assertEcP256PrivateJwk } from './myinfo.v5.crypto' + +export interface DpopKeyPair { + /** Public JWK to be embedded in the DPoP proof JWT header. */ + publicJwk: JWK + /** Imported key used to sign proofs. */ + privateKey: jose.KeyLike + /** Thumbprint, base64url-sha256 of the canonical JWK — for cnf.jkt asserts. */ + thumbprint: string +} + +/** + * Generate an EC P-256 keypair suitable for DPoP. ES256 because that's + * universally supported and what the FAPI 2.0 profile recommends. + */ +export async function generateDpopKeyPair(): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true, + }) + const publicJwk = await jose.exportJWK(publicKey) + const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256') + return { publicJwk, privateKey, thumbprint } +} + +/** + * Re-hydrate a `DpopKeyPair` from a private JWK persisted to a session store. + * The public half is derived by stripping the private components, so we don't + * need to store it twice. + */ +export async function importDpopKeyPair(privateJwk: JWK): Promise { + // Reject anything that isn't the EC P-256 private JWK we wrote. Defends + // against tampered session rows or a future schema drift surfacing the + // wrong key material to jose. + assertEcP256PrivateJwk(privateJwk) + const privateKey = (await jose.importJWK(privateJwk, 'ES256')) as jose.KeyLike + const { + d: _d, + p: _p, + q: _q, + dp: _dp, + dq: _dq, + qi: _qi, + ...publicJwk + } = privateJwk + void _d + void _p + void _q + void _dp + void _dq + void _qi + const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256') + return { publicJwk, privateKey, thumbprint } +} + +/** + * Compute the `ath` claim: base64url(SHA-256(access_token)). + * Required on DPoP proofs sent to resource endpoints (userinfo). + */ +export function computeAccessTokenHash(accessToken: string): string { + return crypto + .createHash('sha256') + .update(accessToken) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +/** + * Build a DPoP proof JWT for a given HTTP request. + * + * @param keypair client's DPoP keypair + * @param htm HTTP method (e.g. 'POST'); normalised to uppercase + * @param htu request URL — strip query and fragment per RFC 9449 §4.2 + * @param accessToken if present, include `ath` for resource-server calls + * @param nonce server-issued DPoP nonce (RFC 9449 §8). Set on retries + * after the AS/RS responds with a `DPoP-Nonce` header. + */ +export async function createDpopProof({ + keypair, + htm, + htu, + accessToken, + nonce, + lifetimeSeconds = 120, +}: { + keypair: DpopKeyPair + htm: string + htu: string + accessToken?: string + nonce?: string + lifetimeSeconds?: number +}): Promise { + const now = Math.floor(Date.now() / 1000) + const url = new URL(htu) + url.search = '' + url.hash = '' + const payload: Record = { + htm: htm.toUpperCase(), + htu: url.toString(), + jti: crypto.randomUUID(), + iat: now, + exp: now + lifetimeSeconds, + } + if (accessToken) { + payload.ath = computeAccessTokenHash(accessToken) + } + if (nonce) { + payload.nonce = nonce + } + return new jose.SignJWT(payload) + .setProtectedHeader({ + typ: 'dpop+jwt', + alg: 'ES256', + jwk: keypair.publicJwk, + }) + .sign(keypair.privateKey) +} diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.errors.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.errors.ts new file mode 100644 index 0000000000..ae895c095c --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.errors.ts @@ -0,0 +1,37 @@ +import { ApplicationError, ErrorCodes } from '../../core/core.errors' + +/** + * v5 configuration is missing or malformed — typically a startup-time issue + * (no JWKS, no issuer URL). Surfacing as a per-request error so a single + * mis-configured form doesn't take the process down. + */ +export class MyInfoV5ConfigError extends ApplicationError { + constructor(message = 'MyInfo v5 is not configured') { + super(message, undefined, ErrorCodes.MYINFO_FETCH) + } +} + +/** + * Failure during the OIDC token exchange or userinfo fetch. + */ +export class MyInfoV5TokenError extends ApplicationError { + constructor(message = 'MyInfo v5 token exchange failed') { + super(message, undefined, ErrorCodes.MYINFO_FETCH) + } +} + +export class MyInfoV5UserinfoError extends ApplicationError { + constructor(message = 'MyInfo v5 userinfo fetch failed') { + super(message, undefined, ErrorCodes.MYINFO_FETCH) + } +} + +/** + * PKCE verifier cookie was missing or could not be matched against the state + * returned by Singpass. + */ +export class MyInfoV5PkceError extends ApplicationError { + constructor(message = 'MyInfo v5 PKCE verifier missing or invalid') { + super(message, undefined, ErrorCodes.MYINFO_PARSE_RELAY_STATE) + } +} diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.factory.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.factory.ts new file mode 100644 index 0000000000..98c6b947a8 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.factory.ts @@ -0,0 +1,58 @@ +/** + * Singleton factory for the v5 service. Kept separate from the class file so + * that the class can be imported and unit-tested without booting the full + * application config (convict pulls in S3, SES, DB env vars that are + * unrelated to MyInfo). + */ + +import type { JSONWebKeySet } from 'jose' + +import config from '../../../config/config' +import { spcpMyInfoConfig } from '../../../config/features/spcp-myinfo.config' +import { retrieveFileContent } from '../../../utils/iac' +import { + MYINFO_ROUTER_PREFIX, + MYINFO_V5_REDIRECT_PATH, +} from '../myinfo.constants' + +import { MyInfoV5ServiceClass } from './myinfo.v5.service' +import type { MyInfoV5RpKeyset } from './myinfo.v5.types' + +function loadJwks( + content: string | null, + path: string | null, +): JSONWebKeySet | null { + if (!content && !path) return null + const raw = retrieveFileContent({ + preIacFilePath: path ?? '', + postIacFileContentString: content ?? '', + }) + if (!raw) return null + return JSON.parse(raw) as JSONWebKeySet +} + +function buildKeyset(): MyInfoV5RpKeyset | null { + const publicJwks = loadJwks( + spcpMyInfoConfig.myInfoV5RpJwksPublic, + spcpMyInfoConfig.myInfoV5RpJwksPublicPath, + ) + const privateJwks = loadJwks( + spcpMyInfoConfig.myInfoV5RpJwksSecret, + spcpMyInfoConfig.myInfoV5RpJwksSecretPath, + ) + if (!publicJwks || !privateJwks) return null + return { publicJwks, privateJwks } +} + +/** + * The dispatcher checks `isConfigured` before routing traffic here, so an + * unconfigured v5 setup is a soft failure (we stay on v3) rather than a + * process crash. + */ +export const MyInfoV5Service = new MyInfoV5ServiceClass({ + issuer: spcpMyInfoConfig.myInfoV5Issuer, + clientId: spcpMyInfoConfig.myInfoV5ClientId, + redirectUri: `${config.app.appUrl}${MYINFO_ROUTER_PREFIX}${MYINFO_V5_REDIRECT_PATH}`, + rpKeyset: buildKeyset(), + dpopEnabled: spcpMyInfoConfig.myInfoV5DpopEnabled, +}) diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.service.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.service.ts new file mode 100644 index 0000000000..dbe95d9e32 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.service.ts @@ -0,0 +1,551 @@ +import axios, { AxiosResponse } from 'axios' +import type { JSONWebKeySet } from 'jose' +import * as jose from 'jose' +import { err, errAsync, okAsync, Result, ResultAsync } from 'neverthrow' + +import { createLoggerWithLabel } from '../../../config/logger' + +import { + createClientAssertion, + deriveCodeChallenge, + generateNonce, + generatePkceVerifier, +} from './myinfo.v5.crypto' +import { createDpopProof, type DpopKeyPair } from './myinfo.v5.dpop' +import { + MyInfoV5ConfigError, + MyInfoV5TokenError, + MyInfoV5UserinfoError, +} from './myinfo.v5.errors' +import type { + MyInfoV5DiscoveryDocument, + MyInfoV5RpKeyset, + MyInfoV5TokenResponse, + MyInfoV5UserinfoClaims, +} from './myinfo.v5.types' + +/** + * Number of seconds of clock skew to tolerate when verifying JWTs from + * Singpass. Pods and the IdP can drift by a handful of seconds; without this + * tolerance, well-formed tokens at the boundary of their validity window + * surface as verification failures. + */ +const JWT_CLOCK_TOLERANCE_SECONDS = 30 + +/** + * RFC 9449 §8 dance: an AS or RS can demand that DPoP proofs include a + * server-issued nonce. The first request is sent without one; if the server + * responds 4xx with a `DPoP-Nonce` header (token endpoint uses 400 + + * `error: "use_dpop_nonce"`, resource endpoints use 401 + + * `WWW-Authenticate: DPoP error="use_dpop_nonce"`), retry once with the + * supplied nonce baked into the proof. A failure on the second attempt + * propagates without further retry — the lambda is only invoked twice at + * most, so this can't loop. + */ +async function withDpopNonceRetry( + attempt: (dpopNonce?: string) => Promise>, +): Promise> { + try { + return await attempt() + } catch (e) { + if (!axios.isAxiosError(e)) throw e + const resp = e.response + if (!resp || (resp.status !== 400 && resp.status !== 401)) throw e + const headers = resp.headers as Record + const dpopNonce = headers['dpop-nonce'] + if (typeof dpopNonce !== 'string' || dpopNonce.length === 0) throw e + return attempt(dpopNonce) + } +} + +const logger = createLoggerWithLabel(module) + +/** + * Service that implements the Singpass Auth API v5 / MyInfo v5 OIDC flow. + * + * Design notes: + * - The `issuer` URL is fully parameterized — mockpass and prod share one + * code path. The discovery doc at `${issuer}/.well-known/openid-configuration` + * provides every other endpoint. + * - The service deliberately uses `Bearer` userinfo auth, matching mockpass. + * Production Singpass v5 requires DPoP (RFC 9449); a TODO marker is left at + * the call site so we don't accidentally flip prod traffic before DPoP lands. + * - The RP keyset (sig + enc EC keys) is loaded once at boot. The public half + * is served at `${appUrl}/api/v3/mi/v5/.well-known/jwks.json` so the IdP can + * fetch it to verify our client_assertion and encrypt the userinfo JWE. + * - The discovery document is cached in-memory after first fetch. Mockpass and + * prod both expose long-lived endpoints; we re-fetch on token/userinfo + * failures by clearing the cache (TODO: explicit invalidation). + */ +export class MyInfoV5ServiceClass { + readonly #issuer: string + readonly #clientId: string + readonly #redirectUri: string + readonly #rpKeyset: MyInfoV5RpKeyset | null + readonly #dpopEnabled: boolean + #discoveryDoc: MyInfoV5DiscoveryDocument | null = null + #idpJwks: ReturnType | null = null + + /** + * Whether v5 has the minimum config needed to operate. Returned to callers + * so the dispatcher can fall back to v3 gracefully when the flag is on but + * v5 hasn't been provisioned (e.g. CI environments where keys aren't set). + */ + get isConfigured(): boolean { + return Boolean(this.#issuer && this.#clientId && this.#rpKeyset) + } + + /** Exposed for tests + diagnostics. */ + get dpopEnabled(): boolean { + return this.#dpopEnabled + } + + constructor({ + issuer, + clientId, + redirectUri, + rpKeyset, + dpopEnabled = false, + }: { + issuer: string + clientId: string + redirectUri: string + rpKeyset: MyInfoV5RpKeyset | null + /** + * RFC 9449 DPoP — required by Singpass v5 in production. Off by default to + * match mockpass (which uses Bearer). Configurable per-instance so the + * same code path serves both. + * + * When enabled, callers MUST pass a per-session DPoP keypair into + * `exchangeCodeForTokens` and `fetchUserinfo`. The same keypair binds the + * access token (token endpoint) to its use on the resource endpoint, so + * losing it between calls invalidates the token. Sessions persist the + * private JWK in Mongo (see `myinfo.v5.session.model.ts`) keyed by a + * session-id cookie so the flow survives load-balancer pod hops. + */ + dpopEnabled?: boolean + }) { + this.#issuer = issuer + this.#clientId = clientId + this.#redirectUri = redirectUri + this.#rpKeyset = rpKeyset + this.#dpopEnabled = dpopEnabled + } + + /** + * Public JWKS (sig + enc), served at the configured endpoint for the IdP to + * fetch. Strips private material defensively. + */ + getPublicJwks(): JSONWebKeySet { + if (!this.#rpKeyset) return { keys: [] } + return { + keys: this.#rpKeyset.publicJwks.keys.map((k) => ({ ...k })), + } + } + + /** + * Lazily fetch the OIDC discovery document. + */ + async #fetchDiscovery(): Promise { + if (this.#discoveryDoc) return this.#discoveryDoc + const url = `${this.#issuer.replace(/\/$/, '')}/.well-known/openid-configuration` + const response = await axios.get(url, { + timeout: 5000, + }) + this.#discoveryDoc = response.data + return response.data + } + + async #getIdpJwks(): Promise> { + if (this.#idpJwks) return this.#idpJwks + const disc = await this.#fetchDiscovery() + this.#idpJwks = jose.createRemoteJWKSet(new URL(disc.jwks_uri)) + return this.#idpJwks + } + + /** + * Build the URL to which the user agent should be redirected to begin the + * Singpass v5 login. Returns the URL plus PKCE/nonce/state material that + * the caller must persist (in cookies) and validate on callback. + */ + createRedirectURL({ + formId, + scopes, + encodedQuery, + }: { + formId: string + scopes: string[] + encodedQuery?: string + }): ResultAsync< + { + redirectURL: string + codeVerifier: string + nonce: string + state: string + }, + MyInfoV5ConfigError + > { + if (!this.isConfigured) { + return errAsync(new MyInfoV5ConfigError()) + } + return ResultAsync.fromPromise(this.#fetchDiscovery(), (error) => { + logger.error({ + message: 'Failed to fetch v5 discovery document', + meta: { action: 'createRedirectURL', issuer: this.#issuer }, + error, + }) + return new MyInfoV5ConfigError('Could not fetch discovery document') + }).map((disc) => { + const codeVerifier = generatePkceVerifier() + const nonce = generateNonce() + const state = encodeState({ formId, encodedQuery }) + + const url = new URL(disc.authorization_endpoint) + url.searchParams.set('client_id', this.#clientId) + url.searchParams.set('scope', scopes.join(' ')) + url.searchParams.set('response_type', 'code') + url.searchParams.set('redirect_uri', this.#redirectUri) + url.searchParams.set('state', state) + url.searchParams.set('nonce', nonce) + url.searchParams.set('code_challenge', deriveCodeChallenge(codeVerifier)) + url.searchParams.set('code_challenge_method', 'S256') + + return { + redirectURL: url.toString(), + codeVerifier, + nonce, + state, + } + }) + } + + /** + * Exchange the auth code for an access token, authenticating with + * `private_key_jwt` per RFC 7523. + * + * Returns the raw token response so the caller can pass `access_token` + * straight to the userinfo call. + */ + exchangeCodeForTokens({ + code, + codeVerifier, + dpopKeypair, + }: { + code: string + codeVerifier: string + /** + * Required when `dpopEnabled` is true. The same keypair MUST be reused + * when calling `fetchUserinfo`, because the access token will be bound to + * its thumbprint. + */ + dpopKeypair?: DpopKeyPair + }): ResultAsync { + if (!this.#rpKeyset) return errAsync(new MyInfoV5TokenError('No keyset')) + if (this.#dpopEnabled && !dpopKeypair) { + return errAsync( + new MyInfoV5TokenError('DPoP enabled but no keypair supplied'), + ) + } + const sigJwk = this.#rpKeyset.privateJwks.keys.find((k) => k.use === 'sig') + if (!sigJwk) return errAsync(new MyInfoV5TokenError('No sig key')) + return ResultAsync.fromPromise( + this.#fetchDiscovery().then(async (disc) => { + const signingKey = (await jose.importJWK( + sigJwk, + 'ES256', + )) as jose.KeyLike + const clientAssertion = await createClientAssertion({ + clientId: this.#clientId, + audience: disc.issuer, + signingKey, + signingKid: sigJwk.kid ?? 'sig-1', + signingAlg: 'ES256', + }) + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.#redirectUri, + client_id: this.#clientId, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: clientAssertion, + code_verifier: codeVerifier, + }) + + // RFC 9449 §5: attach a DPoP proof to the token request so the AS + // binds the issued access token to our session keypair's thumbprint. + // RFC 9449 §8: the AS may demand a server-provided nonce — `withDpopNonceRetry` + // catches `use_dpop_nonce` and retries once with the supplied nonce. + const response = await withDpopNonceRetry( + async (dpopNonce) => { + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded', + } + if (this.#dpopEnabled && dpopKeypair) { + headers.dpop = await createDpopProof({ + keypair: dpopKeypair, + htm: 'POST', + htu: disc.token_endpoint, + nonce: dpopNonce, + }) + } + return axios.post( + disc.token_endpoint, + body.toString(), + { headers, timeout: 10000 }, + ) + }, + ) + return response.data + }), + (error) => { + logger.error({ + message: 'v5 token exchange failed', + meta: { action: 'exchangeCodeForTokens' }, + error, + }) + return new MyInfoV5TokenError() + }, + ) + } + + /** + * Fetch + decrypt the userinfo response, returning the JWS claims. + * + * Wire: GET ${userinfo_endpoint} + * Authorization: {Bearer|DPoP} ${access_token} + * DPoP: (only when dpopEnabled) + * Response: application/jwt — a JWE wrapping a JWS whose payload is the + * set of MyInfo claims. + */ + fetchUserinfo({ + accessToken, + dpopKeypair, + }: { + accessToken: string + /** + * Required when `dpopEnabled` is true. MUST be the same keypair that was + * passed to `exchangeCodeForTokens` — Singpass rejects userinfo calls + * whose DPoP proof key doesn't match the thumbprint bound to the token. + */ + dpopKeypair?: DpopKeyPair + }): ResultAsync { + if (!this.#rpKeyset) { + return errAsync(new MyInfoV5UserinfoError('No keyset')) + } + if (this.#dpopEnabled && !dpopKeypair) { + return errAsync( + new MyInfoV5UserinfoError('DPoP enabled but no keypair supplied'), + ) + } + const encJwk = this.#rpKeyset.privateJwks.keys.find((k) => k.use === 'enc') + if (!encJwk) return errAsync(new MyInfoV5UserinfoError('No enc key')) + return ResultAsync.fromPromise( + (async () => { + const disc = await this.#fetchDiscovery() + // RFC 9449 §7: present the access token under the `DPoP` scheme and + // attach a proof that includes `ath` so the RS can confirm the token + // is being used by the same keypair that was DPoP-bound at issuance. + // RFC 9449 §8: the RS may also demand a server-provided nonce — handled + // by the retry helper. + const response = await withDpopNonceRetry(async (dpopNonce) => { + const headers: Record = { + accept: 'application/jwt', + } + if (this.#dpopEnabled && dpopKeypair) { + headers.authorization = `DPoP ${accessToken}` + headers.dpop = await createDpopProof({ + keypair: dpopKeypair, + htm: 'GET', + htu: disc.userinfo_endpoint, + accessToken, + nonce: dpopNonce, + }) + } else { + headers.authorization = `Bearer ${accessToken}` + } + return axios.get(disc.userinfo_endpoint, { + headers, + // userinfo body is a JWT string; let axios keep it as text. + transformResponse: (raw) => raw, + timeout: 10000, + }) + }) + const jweCompact: string = + typeof response.data === 'string' + ? response.data + : String(response.data) + + // Step 1: decrypt the JWE with our private encryption key. + const encKey = (await jose.importJWK( + encJwk, + (encJwk.alg as string) ?? 'ECDH-ES+A256KW', + )) as jose.KeyLike + const { plaintext } = await jose.compactDecrypt(jweCompact, encKey) + + // Step 2: the JWE payload is a JWS (compact). Verify it against the + // IdP's published signing key, and pin `iss` + `aud` to the + // discovery doc's values so a JWT signed by the same JWKS but + // minted for a different RP cannot pass. + const jws = new TextDecoder().decode(plaintext) + const idpJwks = await this.#getIdpJwks() + const { payload } = await jose.jwtVerify(jws, idpJwks, { + issuer: disc.issuer, + audience: this.#clientId, + clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS, + }) + + return payload as MyInfoV5UserinfoClaims + })(), + (error) => { + logger.error({ + message: 'v5 userinfo fetch/decrypt failed', + meta: { action: 'fetchUserinfo' }, + error, + }) + return new MyInfoV5UserinfoError() + }, + ) + } + + /** + * OIDC §3.1.3.7: the `nonce` claim in the ID Token MUST equal the nonce we + * generated for the authorize request. This defeats replay of a leaked auth + * code: an attacker who didn't observe our nonce cannot get a matching ID + * Token. `iss` is pinned to the discovery doc's issuer and `aud` to our + * client_id at the same time, so a token signed by Singpass for a different + * RP cannot satisfy the check either. + * + * The ID Token from Singpass arrives as either a JWE wrapping a JWS, or a + * bare JWS (depending on `id_token_encryption_alg_values_supported` and the + * mockpass profile). We detect by segment count and handle both. + */ + verifyIdToken({ + idToken, + expectedNonce, + }: { + idToken: string + expectedNonce: string + }): ResultAsync<{ nonce?: string; sub?: string }, MyInfoV5UserinfoError> { + if (!this.#rpKeyset) { + return errAsync(new MyInfoV5UserinfoError('No keyset')) + } + // A JOSE compact JWE has 5 base64url segments; a JWS has 3. + const segments = idToken.split('.').length + const encJwk = this.#rpKeyset.privateJwks.keys.find((k) => k.use === 'enc') + if (segments === 5 && !encJwk) { + return errAsync( + new MyInfoV5UserinfoError( + 'id_token is encrypted but no enc key available', + ), + ) + } + if (segments !== 3 && segments !== 5) { + return errAsync( + new MyInfoV5UserinfoError( + `Unexpected id_token segment count: ${segments}`, + ), + ) + } + return ResultAsync.fromPromise( + (async () => { + const disc = await this.#fetchDiscovery() + let jws: string + if (segments === 5 && encJwk) { + const encKey = (await jose.importJWK( + encJwk, + (encJwk.alg as string) ?? 'ECDH-ES+A256KW', + )) as jose.KeyLike + const { plaintext } = await jose.compactDecrypt(idToken, encKey) + jws = new TextDecoder().decode(plaintext) + } else { + jws = idToken + } + const idpJwks = await this.#getIdpJwks() + // OIDC §3.1.3.7: `iss` MUST match the discovery doc, `aud` MUST contain + // our client_id, `exp` MUST be in the future. `jose` checks exp by + // default; iss + aud need to be passed explicitly. + const { payload } = await jose.jwtVerify(jws, idpJwks, { + issuer: disc.issuer, + audience: this.#clientId, + clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS, + }) + return payload + })(), + (error) => { + logger.error({ + message: 'v5 id_token verification failed', + meta: { action: 'verifyIdToken' }, + error, + }) + return new MyInfoV5UserinfoError() + }, + ).andThen((payload) => + payload.nonce === expectedNonce + ? okAsync({ + nonce: + typeof payload.nonce === 'string' ? payload.nonce : undefined, + sub: typeof payload.sub === 'string' ? payload.sub : undefined, + }) + : errAsync( + new MyInfoV5UserinfoError( + 'id_token nonce mismatch — possible replay', + ), + ), + ) + } +} + +/** + * Encode formId + encodedQuery into the OAuth `state` parameter. + * v3 uses a JSON string for this; we keep the same shape so error logging + * and downstream parsing look familiar. + */ +function encodeState({ + formId, + encodedQuery, +}: { + formId: string + encodedQuery?: string +}): string { + return Buffer.from( + JSON.stringify({ formId, encodedQuery, v: 5 }), + 'utf8', + ).toString('base64url') +} + +export function decodeV5State( + state: string, +): Result<{ formId: string; encodedQuery?: string }, Error> { + // JSON.parse is the only throw source — wrap it with fromThrowable and + // narrow the shape afterwards so we never throw ourselves. + const parsed = Result.fromThrowable( + () => + JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as { + formId?: unknown + encodedQuery?: unknown + v?: unknown + }, + (e) => e as Error, + )() + return parsed.andThen((decoded) => + typeof decoded.formId === 'string' + ? Result.fromThrowable< + () => { formId: string; encodedQuery?: string }, + Error + >( + () => ({ + formId: decoded.formId as string, + encodedQuery: + typeof decoded.encodedQuery === 'string' + ? decoded.encodedQuery + : undefined, + }), + (e) => e as Error, + )() + : err<{ formId: string; encodedQuery?: string }, Error>( + new Error('formId missing'), + ), + ) +} diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.session.model.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.session.model.ts new file mode 100644 index 0000000000..fe38672f46 --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.session.model.ts @@ -0,0 +1,121 @@ +/** + * MongoDB-backed session store for a single v5 login round trip. + * + * Why a server-side store and not a cookie: + * - DPoP needs the *private* JWK across two HTTP requests (redirect → callback). + * Putting a private EC key in a cookie is gross and brittle. + * - FormSG runs multi-pod. A user's two requests can land on different pods, + * so the keypair must be retrievable from anywhere. + * + * Why a dedicated collection and not session middleware: + * - These sessions are pre-authentication and short-lived (≤5 min). Mixing + * them with the authenticated user session would either bloat that store or + * require complex cleanup. A self-cleaning TTL collection keeps responsibility + * tight. + * + * Lifecycle: + * 1. createRedirectURL → generate PKCE + DPoP keypair, save session, return + * sessionId. + * 2. Cookie carrying sessionId follows the user to Singpass and back. + * 3. Callback / form-view → load by sessionId, use codeVerifier + DPoP + * keypair, then deleteOne(). One-shot semantics — a session can't be + * reused for a replay. + */ + +import crypto from 'crypto' +import { Mongoose, Schema } from 'mongoose' + +const SCHEMA_ID = 'MyInfoV5Session' +const SESSION_TTL_MS = 5 * 60 * 1000 + +export interface IMyInfoV5Session { + _id: string + /** PKCE code verifier (RFC 7636) for the token exchange. */ + codeVerifier: string + /** + * AES-256-GCM-encrypted envelope holding the DPoP keypair's private JWK, + * produced by `encryptJwkAtRest` with a key derived from `config.sessionSecret`. + * Absent when DPoP is off. Storing the ciphertext (not the JWK directly) + * means a DB-only compromise can't reuse the keypair within the 5-minute + * session window. + */ + dpopPrivateJwkEnc?: string + /** + * OIDC nonce we sent on the authorize request. The IdP echoes it back in + * the ID token; we verify equality after token exchange to defeat token- + * replay attempts. + */ + nonce?: string + expireAt: Date +} + +export interface IMyInfoV5SessionModel { + createSession(args: { + codeVerifier: string + dpopPrivateJwkEnc?: string + nonce?: string + }): Promise + /** One-shot: returns the session and deletes it. */ + consumeSession(sessionId: string): Promise +} + +type ModelInstance = import('mongoose').Model & + IMyInfoV5SessionModel + +const schema = new Schema( + { + _id: { + type: String, + required: true, + }, + codeVerifier: { type: String, required: true }, + // Typed as String (the encrypted envelope) rather than Object/Mixed. This + // keeps the column to a single shape and prevents tampered rows from + // smuggling arbitrary JWK material into `jose.importJWK`. + dpopPrivateJwkEnc: { type: String, required: false }, + nonce: { type: String, required: false }, + expireAt: { type: Date, required: true }, + }, + { timestamps: { createdAt: 'created', updatedAt: false }, _id: false }, +) + +// TTL — Mongo deletes documents at most a minute after `expireAt`. Combined +// with `consumeSession` doing an explicit delete, sessions are gone the +// moment they're used. +schema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }) + +schema.statics.createSession = async function (args: { + codeVerifier: string + dpopPrivateJwkEnc?: string + nonce?: string +}): Promise { + const sessionId = crypto.randomUUID() + return this.create({ + _id: sessionId, + codeVerifier: args.codeVerifier, + dpopPrivateJwkEnc: args.dpopPrivateJwkEnc, + nonce: args.nonce, + expireAt: new Date(Date.now() + SESSION_TTL_MS), + }) +} + +schema.statics.consumeSession = async function ( + sessionId: string, +): Promise { + return this.findOneAndDelete({ _id: sessionId }) +} + +function compile(db: Mongoose): ModelInstance { + return db.model( + SCHEMA_ID, + schema, + ) as unknown as ModelInstance +} + +export default function getMyInfoV5SessionModel(db: Mongoose): ModelInstance { + try { + return db.model(SCHEMA_ID) as unknown as ModelInstance + } catch { + return compile(db) + } +} diff --git a/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.types.ts b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.types.ts new file mode 100644 index 0000000000..55630779ec --- /dev/null +++ b/apps/backend/src/app/modules/myinfo/v5/myinfo.v5.types.ts @@ -0,0 +1,95 @@ +/** + * Types for the MyInfo v5 / Singpass Auth API v5 flow. + * + * Singpass v5 collapses what used to be a separate MyInfo Person endpoint into + * the OIDC `userinfo` endpoint of the Auth API. Person attributes are returned + * as claims on the userinfo JWT, wrapped in a JWE. + * + * For the mockpass-compatible subset we model only what we read. Production + * Singpass returns additional structured claims (e.g. `sub_attributes`, nested + * `person_info` objects) which the adapter handles defensively. + */ + +import type { JSONWebKeySet } from 'jose' + +/** + * Standard OIDC discovery document fields we depend on. + */ +export interface MyInfoV5DiscoveryDocument { + issuer: string + authorization_endpoint: string + token_endpoint: string + userinfo_endpoint: string + jwks_uri: string + id_token_signing_alg_values_supported?: string[] + userinfo_signing_alg_values_supported?: string[] + userinfo_encryption_alg_values_supported?: string[] + userinfo_encryption_enc_values_supported?: string[] +} + +export interface MyInfoV5TokenResponse { + access_token: string + token_type: 'Bearer' | 'DPoP' + id_token?: string + expires_in?: number + scope?: string +} + +/** + * Lightweight relay-state container persisted in cookies between the redirect + * and the OAuth callback. PKCE + nonce live alongside the formId so we can + * validate the round trip and pop the user back where they came from. + */ +export interface MyInfoV5RelayPayload { + formId: string + encodedQuery?: string + codeVerifier: string + nonce: string + state: string +} + +/** + * Shape of the userinfo claims set after JWE decrypt + JWS verify. + * Singpass returns each MyInfo attribute as a claim with `{value, source, lastupdated, classification}`. + * Mockpass returns the bare minimum (uinfin, name) using the same shape. + * + * We accept `unknown` per-claim and let the adapter narrow per attribute. + */ +export interface MyInfoV5UserinfoClaims { + sub: string + iss: string + aud: string + iat: number + // Each MyInfo attribute appears as its own claim key. + // E.g. `uinfin`, `name`, `mobileno`, `regadd`, `dob`, ... + [claim: string]: unknown +} + +/** + * Shape of an RP-controlled JWKS file on disk. + * One JWKS may contain both signature and encryption keys, distinguished by + * `use`. We require at least one of each for a complete v5 setup. + */ +export interface MyInfoV5RpKeyset { + publicJwks: JSONWebKeySet + privateJwks: JSONWebKeySet +} + +export interface MyInfoV5ServiceConfig { + /** + * Issuer URL — `${issuer}/.well-known/openid-configuration` returns the + * discovery document. Mockpass: `http://localhost:5156/singpass/v2`. + * Prod: typically the Singpass Auth API v5 base URL. + */ + issuer: string + clientId: string + /** Where Singpass should send the user back. */ + redirectUri: string + rpKeyset: MyInfoV5RpKeyset + /** + * Scopes to request. Singpass v5 scopes that map to MyInfo attributes: + * 'openid' (required), 'uinfin', 'name', 'mobileno', 'regadd', ... + * The dispatcher derives this from the form's requested MyInfo attributes. + */ + scopes: string[] +} diff --git a/apps/backend/src/app/routes/singpass/sp.oidc.jwks.routes.ts b/apps/backend/src/app/routes/singpass/sp.oidc.jwks.routes.ts index 1453557224..ff18216902 100644 --- a/apps/backend/src/app/routes/singpass/sp.oidc.jwks.routes.ts +++ b/apps/backend/src/app/routes/singpass/sp.oidc.jwks.routes.ts @@ -1,21 +1,32 @@ import { Router } from 'express' +import type { JSONWebKeySet } from 'jose' import { spcpMyInfoConfig } from '../../config/features/spcp-myinfo.config' +import { MyInfoV5Service } from '../../modules/myinfo/v5/myinfo.v5.factory' import { retrieveJsonContent } from '../../utils/iac' // Handles SingPass JWKS requests export const SpOidcJwksRouter = Router() /** - * Returns the RP's public json web key set (JWKS) for communication with NDI + * Returns the RP's public json web key set (JWKS) for communication with NDI. + * + * In production the SPCP OIDC v1 flow and the MyInfo v5 / Singpass Auth v2 flow + * register their own JWKS URLs separately. mockpass, however, only reads one + * URL per IdP — so in dev (and CI) we merge both keysets here. + * * @route GET /sp/.well-known/jwks.json * @returns 200 */ SpOidcJwksRouter.get('/', (_req, res) => { - res.json( - retrieveJsonContent({ - preIacFilePath: spcpMyInfoConfig.spOidcRpJwksPublicPath, - postIacJsonString: spcpMyInfoConfig.spOidcRpJwksPublic, - }), - ) + const spcpJwks = retrieveJsonContent({ + preIacFilePath: spcpMyInfoConfig.spOidcRpJwksPublicPath, + postIacJsonString: spcpMyInfoConfig.spOidcRpJwksPublic, + }) as JSONWebKeySet | undefined + + const v5Jwks = MyInfoV5Service.getPublicJwks() + const merged: JSONWebKeySet = { + keys: [...(spcpJwks?.keys ?? []), ...v5Jwks.keys], + } + res.json(merged) }) diff --git a/docker-compose.yml b/docker-compose.yml index 02d4dc8c1d..acbd09cca1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,15 @@ services: - MYINFO_CLIENT_ID=mockClientId - MYINFO_CLIENT_SECRET=mockClientSecret - MYINFO_JWT_SECRET=mockJwtSecret + # MyInfo v5 — Singpass Auth API v5. The issuer URL is parameterized so + # the same code path works against mockpass and prod (different host). + - MYINFO_V5_ISSUER=http://localhost:5156/singpass/v2 + - MYINFO_V5_CLIENT_ID=mockClientId + # Generated by `pnpm --filter formsg-backend gen-dev-rp-keys` at + # container startup (Dockerfile.development CMD). The .dev-keys/ dir + # is gitignored — no private key material is checked into the repo. + - MYINFO_V5_RP_JWKS_PUBLIC_PATH=.dev-keys/rp-v5-public.json + - MYINFO_V5_RP_JWKS_SECRET_PATH=.dev-keys/rp-v5-secret.json - SGID_HOSTNAME=http://localhost:5156 - SGID_CLIENT_ID=sgidclientid - SGID_CLIENT_SECRET=sgidclientsecret @@ -145,13 +154,19 @@ services: - SSO_CLIENT_SECRET mockpass: - image: opengovsg/mockpass:4.6.4 + # Bumped to 4.6.7 to pick up Singpass Auth v2 (== MyInfo v5) userinfo + # mocking. See https://github.com/opengovsg/mockpass/pull/737. + image: opengovsg/mockpass:4.6.7 depends_on: - backend environment: - MOCKPASS_NRIC=S6005038D - MOCKPASS_UEN=123456789A - SHOW_LOGIN_PAGE=true + # Mockpass reads ONE endpoint per IdP for both SPCP OIDC v1 (Singpass login) + # and Singpass Auth v2 / MyInfo v5. The /sp/.well-known/jwks.json endpoint + # merges both keysets in dev so both flows work. In prod each flow registers + # its own URL with Singpass. - SP_RP_JWKS_ENDPOINT=http://localhost:5000/sp/.well-known/jwks.json - CP_RP_JWKS_ENDPOINT=http://localhost:5000/api/v3/corppass/.well-known/jwks.json network_mode: 'service:backend' # reuse backend service's network stack so that it can resolve localhost:5156 to mockpass:5156 diff --git a/package.json b/package.json index 7c1b76a94d..59d050a07c 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ "lint-staged": "^15.2.10", "prettier": "^3.5.3", "rimraf": "^5.0.5", - "skills": "^1.5.7" + "skills": "^1.5.7", + "tsx": "^4.22.3" }, "pnpm": { "overrides": { diff --git a/packages/shared/constants/feature-flags.ts b/packages/shared/constants/feature-flags.ts index 66d3220fd2..0cfbc3c075 100644 --- a/packages/shared/constants/feature-flags.ts +++ b/packages/shared/constants/feature-flags.ts @@ -43,6 +43,7 @@ export const featureFlags = { standardisedEmailTemplate: 'standardised-email-template' as const, mrfCutover: 'mrf-cutover' as const, answerObjectEncryption: 'answer-object-encryption' as const, + switchMyinfoV5: 'switch-myinfo-v5' as const, } export enum AdminEmailPdfFeatureValue { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c17857705..1943a27303 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: skills: specifier: ^1.5.7 version: 1.5.7 + tsx: + specifier: ^4.22.3 + version: 4.22.3 apps/backend: dependencies: @@ -708,6 +711,9 @@ importers: ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@types/node@24.0.10)(typescript@5.9.3) + tsx: + specifier: ^4.22.3 + version: 4.22.3 type-fest: specifier: ^4.17.0 version: 4.41.0 @@ -1019,7 +1025,7 @@ importers: version: 13.15.26 vite-plugin-node-stdlib-browser: specifier: ^0.2.1 - version: 0.2.1(node-stdlib-browser@1.3.1)(rollup@4.59.0)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 0.2.1(node-stdlib-browser@1.3.1)(rollup@4.59.0)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) zustand: specifier: ^4.1.1 version: 4.1.1(immer@9.0.21)(react@18.3.1) @@ -1056,7 +1062,7 @@ importers: version: 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3) '@storybook/react-vite': specifier: 8.6.18 - version: 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) '@storybook/test': specifier: 8.6.18 version: 8.6.18(storybook@8.6.18(prettier@3.8.1)) @@ -1125,7 +1131,7 @@ importers: version: 8.26.1(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.3.1(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.3.1(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) chromatic: specifier: ^6.2.0 version: 6.2.0 @@ -1185,16 +1191,16 @@ importers: version: 8.6.18(prettier@3.8.1) vite: specifier: ^6.4.2 - version: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + version: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) vite-plugin-svgr: specifier: ^4.2.0 - version: 4.2.0(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.2.0(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) vite-tsconfig-paths: specifier: ^4.3.2 - version: 4.3.2(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.3.2(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(yaml@2.8.4) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(tsx@4.22.3)(yaml@2.8.4) packages/react-email-preview: dependencies: @@ -1216,7 +1222,7 @@ importers: version: 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3) '@storybook/react-vite': specifier: 8.6.18 - version: 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) '@types/react': specifier: 18.2.33 version: 18.2.33 @@ -1228,7 +1234,7 @@ importers: version: 8.6.18(prettier@3.8.1) vite: specifier: ^6.4.2 - version: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + version: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) packages/sdk: dependencies: @@ -1332,7 +1338,7 @@ importers: version: 5.5.5(eslint-config-prettier@10.1.5(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.1) jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.9.3)) + version: 29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0) mongoose: specifier: ~7.8.9 version: 7.8.9 @@ -3051,156 +3057,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.9': resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.9': resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.9': resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.9': resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.9': resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.9': resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.9': resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.9': resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.9': resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.9': resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.9': resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.9': resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.9': resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.9': resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.9': resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.9': resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.9': resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.9': resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.9': resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.9': resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.9': resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.9': resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.9': resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.9': resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.9': resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -7898,6 +8060,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -13371,6 +13538,11 @@ packages: peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + ttl-set@1.0.0: resolution: {integrity: sha512-2fuHn/UR+8Z9HK49r97+p2Ru1b5Eewg2QqPrU14BVCQ9QoyU3+vLLZk2WEiyZ9sgJh6W8G1cZr9I2NBLywAHrA==} @@ -14699,8 +14871,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sso-oidc': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/core': 3.576.0 '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -14833,11 +15005,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0)': + '@aws-sdk/client-sso-oidc@3.577.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/core': 3.576.0 '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -14876,7 +15048,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.693.0(@aws-sdk/client-sts@3.693.0)': @@ -15053,11 +15224,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.577.0': + '@aws-sdk/client-sts@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/client-sso-oidc': 3.577.0 '@aws-sdk/core': 3.576.0 '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -15096,6 +15267,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.693.0': @@ -15324,7 +15496,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0)': dependencies: - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/credential-provider-env': 3.577.0 '@aws-sdk/credential-provider-process': 3.577.0 '@aws-sdk/credential-provider-sso': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) @@ -15641,7 +15813,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.577.0(@aws-sdk/client-sts@3.577.0)': dependencies: - '@aws-sdk/client-sts': 3.577.0 + '@aws-sdk/client-sts': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -16155,7 +16327,7 @@ snapshots: '@aws-sdk/token-providers@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/client-sso-oidc': 3.577.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -17649,81 +17821,159 @@ snapshots: '@esbuild/aix-ppc64@0.25.9': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.25.9': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.25.9': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.25.9': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.25.9': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.25.9': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.25.9': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.25.9': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.25.9': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.25.9': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.25.9': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.25.9': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.25.9': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.25.9': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.25.9': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.25.9': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.25.9': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.9': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.25.9': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.9': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.25.9': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.9': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.25.9': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.25.9': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.25.9': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.25.9': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -18496,12 +18746,12 @@ snapshots: dependencies: moment: 2.29.4 - '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4))': dependencies: glob: 10.5.0 magic-string: 0.27.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) optionalDependencies: typescript: 5.9.3 @@ -20152,13 +20402,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.6.18(storybook@8.6.18(prettier@3.8.1))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4))': + '@storybook/builder-vite@8.6.18(storybook@8.6.18(prettier@3.8.1))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4))': dependencies: '@storybook/csf-plugin': 8.6.18(storybook@8.6.18(prettier@3.8.1)) browser-assert: 1.2.1 storybook: 8.6.18(prettier@3.8.1) ts-dedent: 2.2.0 - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) '@storybook/components@8.6.18(storybook@8.6.18(prettier@3.8.1))': dependencies: @@ -20221,11 +20471,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.18(prettier@3.8.1) - '@storybook/react-vite@8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4))': + '@storybook/react-vite@8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.59.0)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.5.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 8.6.18(storybook@8.6.18(prettier@3.8.1))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + '@storybook/builder-vite': 8.6.18(storybook@8.6.18(prettier@3.8.1))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) '@storybook/react': 8.6.18(@storybook/test@8.6.18(storybook@8.6.18(prettier@3.8.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.18(prettier@3.8.1))(typescript@5.9.3) find-up: 5.0.0 magic-string: 0.30.21 @@ -20235,7 +20485,7 @@ snapshots: resolve: 1.22.8 storybook: 8.6.18(prettier@3.8.1) tsconfig-paths: 4.2.0 - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) optionalDependencies: '@storybook/test': 8.6.18(storybook@8.6.18(prettier@3.8.1)) transitivePeerDependencies: @@ -21082,14 +21332,14 @@ snapshots: '@virtuoso.dev/urx@0.2.13': {} - '@vitejs/plugin-react@4.3.1(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitejs/plugin-react@4.3.1(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -21108,14 +21358,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitest/mocker@3.2.4(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.10.5(@types/node@24.0.10)(typescript@5.9.3) - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) '@vitest/pretty-format@2.0.5': dependencies: @@ -23678,6 +23928,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.9 '@esbuild/win32-x64': 0.25.9 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -25580,6 +25859,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0): + dependencies: + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.9.3)) @@ -26323,6 +26621,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0): + dependencies: + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@24.0.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.9.3)) @@ -30843,6 +31153,12 @@ snapshots: tslib: 1.14.1 typescript: 5.9.3 + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + ttl-set@1.0.0: dependencies: fast-fifo: 1.3.2 @@ -31268,13 +31584,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4): + vite-node@3.2.4(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) transitivePeerDependencies: - '@types/node' - jiti @@ -31289,37 +31605,37 @@ snapshots: - tsx - yaml - vite-plugin-node-stdlib-browser@0.2.1(node-stdlib-browser@1.3.1)(rollup@4.59.0)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)): + vite-plugin-node-stdlib-browser@0.2.1(node-stdlib-browser@1.3.1)(rollup@4.59.0)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)): dependencies: '@rollup/plugin-inject': 5.0.5(rollup@4.59.0) node-stdlib-browser: 1.3.1 - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) transitivePeerDependencies: - rollup - vite-plugin-svgr@4.2.0(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)): + vite-plugin-svgr@4.2.0(rollup@4.59.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)): + vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)): dependencies: debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) transitivePeerDependencies: - supports-color - typescript - vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4): + vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.4) @@ -31331,13 +31647,14 @@ snapshots: '@types/node': 24.0.10 fsevents: 2.3.3 lightningcss: 1.32.0 + tsx: 4.22.3 yaml: 2.8.4 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(yaml@2.8.4): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(tsx@4.22.3)(yaml@2.8.4): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4)) + '@vitest/mocker': 3.2.4(msw@2.10.5(@types/node@24.0.10)(typescript@5.9.3))(vite@6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -31355,8 +31672,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) - vite-node: 3.2.4(@types/node@24.0.10)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 6.4.2(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) + vite-node: 3.2.4(@types/node@24.0.10)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12