Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion apps/backend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = {
es6: true,
node: true,
},
ignorePatterns: [],
ignorePatterns: ['scripts/'],
extends: ['eslint:recommended', 'plugin:prettier/recommended'],
globals: {
Atomics: 'readonly',
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
82 changes: 82 additions & 0 deletions apps/backend/scripts/generate-dev-rp-keys.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
})
191 changes: 191 additions & 0 deletions apps/backend/scripts/smoke-myinfo-v5.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void>((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<string>(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)
})
Loading
Loading