From 77bbb0d3b6ad18157f6f5f4dfd98bfb65f0a9883 Mon Sep 17 00:00:00 2001 From: LoneRifle Date: Thu, 8 Feb 2024 10:30:03 +0800 Subject: [PATCH] feat: allow stateless operation with base64url-encoded tokens For stateless environments where we cannot guarantee the state of a process, such as AWS Lambda or Netlify Functions, allow auth code and token generation to be base64url encodings of the values they are meant to represent, rather than random characters --- app.js | 5 ++++- lib/auth-code.js | 18 ++++++++++++++---- lib/express/myinfo/consent.js | 4 ++-- lib/express/oidc/spcp.js | 28 ++++++++++++++++++++-------- lib/express/oidc/v2-ndi.js | 10 +++++----- lib/express/sgid.js | 20 +++++++++++++++----- 6 files changed, 60 insertions(+), 25 deletions(-) diff --git a/app.js b/app.js index 86d8918d..db524af7 100755 --- a/app.js +++ b/app.js @@ -35,12 +35,15 @@ const cryptoConfig = { process.env.RESOLVE_ARTIFACT_REQUEST_SIGNED !== 'false', } +const isStateless = process.env.MOCKPASS_STATELESS === 'true' + const options = { serviceProvider, showLoginPage: (req) => (req.header('X-Show-Login-Page') || process.env.SHOW_LOGIN_PAGE) === 'true', encryptMyInfo: process.env.ENCRYPT_MYINFO === 'true', cryptoConfig, + isStateless, } const app = express() @@ -50,7 +53,7 @@ configOIDC(app, options) configOIDCv2(app, options) configSGID(app, options) -configMyInfo.consent(app) +configMyInfo.consent(app, options) configMyInfo.v3(app, options) app.enable('trust proxy') diff --git a/lib/auth-code.js b/lib/auth-code.js index 5fd95477..6d996a1b 100644 --- a/lib/auth-code.js +++ b/lib/auth-code.js @@ -4,14 +4,24 @@ const crypto = require('crypto') const AUTH_CODE_TIMEOUT = 5 * 60 * 1000 const profileAndNonceStore = new ExpiryMap(AUTH_CODE_TIMEOUT) -const generateAuthCode = ({ profile, scopes, nonce }) => { - const authCode = crypto.randomBytes(45).toString('base64') +const generateAuthCode = ( + { profile, scopes, nonce }, + { isStateless = false }, +) => { + const authCode = isStateless + ? Buffer.from(JSON.stringify({ profile, scopes, nonce })).toString( + 'base64url', + ) + : crypto.randomBytes(45).toString('base64') + profileAndNonceStore.set(authCode, { profile, scopes, nonce }) return authCode } -const lookUpByAuthCode = (authCode) => { - return profileAndNonceStore.get(authCode) +const lookUpByAuthCode = (authCode, { isStateless = false }) => { + return isStateless + ? JSON.parse(Buffer.from(authCode, 'base64url').toString('utf-8')) + : profileAndNonceStore.get(authCode) } module.exports = { generateAuthCode, lookUpByAuthCode } diff --git a/lib/express/myinfo/consent.js b/lib/express/myinfo/consent.js index 9f7e867f..4671a6fe 100644 --- a/lib/express/myinfo/consent.js +++ b/lib/express/myinfo/consent.js @@ -47,13 +47,13 @@ const authorizeViaOIDC = authorize( `/singpass/authorize?client_id=MYINFO-CONSENTPLATFORM&redirect_uri=${MYINFO_ASSERT_ENDPOINT}&state=${state}`, ) -function config(app) { +function config(app, { isStateless }) { app.get(MYINFO_ASSERT_ENDPOINT, (req, res) => { const rawArtifact = req.query.SAMLart || req.query.code const artifact = rawArtifact.replace(/ /g, '+') const state = req.query.RelayState || req.query.state - const profile = lookUpByAuthCode(artifact).profile + const profile = lookUpByAuthCode(artifact, { isStateless }).profile const myinfoVersion = 'v3' const { nric: id } = profile diff --git a/lib/express/oidc/spcp.js b/lib/express/oidc/spcp.js index de2d083c..bf76b69d 100644 --- a/lib/express/oidc/spcp.js +++ b/lib/express/oidc/spcp.js @@ -24,7 +24,7 @@ const signingPem = fs.readFileSync( path.resolve(__dirname, '../../../static/certs/spcp-key.pem'), ) -function config(app, { showLoginPage, serviceProvider }) { +function config(app, { showLoginPage, serviceProvider, isStateless }) { for (const idp of ['singPass', 'corpPass']) { const profiles = assertions.oidc[idp] const defaultProfile = @@ -34,7 +34,7 @@ function config(app, { showLoginPage, serviceProvider }) { const { redirect_uri: redirectURI, state, nonce } = req.query if (showLoginPage(req)) { const values = profiles.map((profile) => { - const authCode = generateAuthCode({ profile, nonce }) + const authCode = generateAuthCode({ profile, nonce }, { isStateless }) const assertURL = buildAssertURL(redirectURI, authCode, state) const id = idGenerator[idp](profile) return { id, assertURL } @@ -53,7 +53,7 @@ function config(app, { showLoginPage, serviceProvider }) { res.send(response) } else { const profile = customProfileFromHeaders[idp](req) || defaultProfile - const authCode = generateAuthCode({ profile, nonce }) + const authCode = generateAuthCode({ profile, nonce }, { isStateless }) const assertURL = buildAssertURL(redirectURI, authCode, state) console.warn( `Redirecting login from ${req.query.client_id} to ${redirectURI}`, @@ -72,7 +72,7 @@ function config(app, { showLoginPage, serviceProvider }) { profile.uen = uen } - const authCode = generateAuthCode({ profile, nonce }) + const authCode = generateAuthCode({ profile, nonce }, { isStateless }) const assertURL = buildAssertURL(redirectURI, authCode, state) res.redirect(assertURL) }) @@ -88,20 +88,32 @@ function config(app, { showLoginPage, serviceProvider }) { const { refresh_token: suppliedRefreshToken } = req.body console.warn(`Refreshing tokens with ${suppliedRefreshToken}`) - profile = profileStore.get(suppliedRefreshToken) + profile = isStateless + ? JSON.parse( + Buffer.from(suppliedRefreshToken, 'base64url').toString( + 'utf-8', + ), + ) + : profileStore.get(suppliedRefreshToken) } else { const { code: authCode } = req.body console.warn( `Received auth code ${authCode} from ${aud} and ${req.body.redirect_uri}`, ) - ;({ profile, nonce } = lookUpByAuthCode(authCode)) + ;({ profile, nonce } = lookUpByAuthCode(authCode, { isStateless })) } const iss = `${req.protocol}://${req.get('host')}` - const { idTokenClaims, accessToken, refreshToken } = - await assertions.oidc.create[idp](profile, iss, aud, nonce) + const { + idTokenClaims, + accessToken, + refreshToken: generatedRefreshToken, + } = await assertions.oidc.create[idp](profile, iss, aud, nonce) + const refreshToken = isStateless + ? Buffer.from(JSON.stringify(profile)).toString('base64url') + : generatedRefreshToken profileStore.set(refreshToken, profile) const signingKey = await jose.JWK.asKey(signingPem, 'pem') diff --git a/lib/express/oidc/v2-ndi.js b/lib/express/oidc/v2-ndi.js index 2d11be16..3871aa36 100644 --- a/lib/express/oidc/v2-ndi.js +++ b/lib/express/oidc/v2-ndi.js @@ -140,7 +140,7 @@ function findEncryptionKey(jwks, algs) { } } -function config(app, { showLoginPage }) { +function config(app, { showLoginPage, isStateless }) { for (const idp of ['singPass', 'corpPass']) { const profiles = assertions.oidc[idp] const defaultProfile = @@ -196,7 +196,7 @@ function config(app, { showLoginPage }) { // Identical to OIDC v1 if (showLoginPage(req)) { const values = profiles.map((profile) => { - const authCode = generateAuthCode({ profile, nonce }) + const authCode = generateAuthCode({ profile, nonce }, { isStateless }) const assertURL = buildAssertURL(redirectURI, authCode, state) const id = idGenerator[idp](profile) return { id, assertURL } @@ -215,7 +215,7 @@ function config(app, { showLoginPage }) { res.send(response) } else { const profile = customProfileFromHeaders[idp](req) || defaultProfile - const authCode = generateAuthCode({ profile, nonce }) + const authCode = generateAuthCode({ profile, nonce }, { isStateless }) const assertURL = buildAssertURL(redirectURI, authCode, state) console.warn( `Redirecting login from ${req.query.client_id} to ${redirectURI}`, @@ -234,7 +234,7 @@ function config(app, { showLoginPage }) { profile.uen = uen } - const authCode = generateAuthCode({ profile, nonce }) + const authCode = generateAuthCode({ profile, nonce }, { isStateless }) const assertURL = buildAssertURL(redirectURI, authCode, state) res.redirect(assertURL) }) @@ -434,7 +434,7 @@ function config(app, { showLoginPage }) { } // Step 1: Obtain profile for which the auth code requested data for - const { profile, nonce } = lookUpByAuthCode(authCode) + const { profile, nonce } = lookUpByAuthCode(authCode, { isStateless }) // Step 2: Get ID token const aud = clientAssertionClaims['sub'] diff --git a/lib/express/sgid.js b/lib/express/sgid.js index 29a35c44..96e8c094 100644 --- a/lib/express/sgid.js +++ b/lib/express/sgid.js @@ -30,7 +30,7 @@ const buildAssertURL = (redirectURI, authCode, state) => authCode, )}&state=${encodeURIComponent(state)}` -function config(app, { showLoginPage, serviceProvider }) { +function config(app, { showLoginPage, serviceProvider, isStateless }) { const profiles = assertions.oidc.singPass const defaultProfile = profiles.find((p) => p.nric === process.env.MOCKPASS_NRIC) || profiles[0] @@ -43,7 +43,10 @@ function config(app, { showLoginPage, serviceProvider }) { const values = profiles .filter((profile) => assertions.myinfo.v3.personas[profile.nric]) .map((profile) => { - const authCode = generateAuthCode({ profile, scopes, nonce }) + const authCode = generateAuthCode( + { profile, scopes, nonce }, + { isStateless }, + ) const assertURL = buildAssertURL(redirectURI, authCode, state) const id = idGenerator.singPass(profile) return { id, assertURL } @@ -52,7 +55,10 @@ function config(app, { showLoginPage, serviceProvider }) { res.send(response) } else { const profile = defaultProfile - const authCode = generateAuthCode({ profile, scopes, nonce }) + const authCode = generateAuthCode( + { profile, scopes, nonce }, + { isStateless }, + ) const assertURL = buildAssertURL(redirectURI, authCode, state) console.info( `Redirecting login from ${req.query.client_id} to ${assertURL}`, @@ -74,7 +80,9 @@ function config(app, { showLoginPage, serviceProvider }) { ) try { - const { profile, scopes, nonce } = lookUpByAuthCode(authCode) + const { profile, scopes, nonce } = lookUpByAuthCode(authCode, { + isStateless, + }) console.info( `Profile ${JSON.stringify(profile)} with token scope ${scopes}`, ) @@ -120,7 +128,9 @@ function config(app, { showLoginPage, serviceProvider }) { req.headers.authorization || req.headers.Authorization ).replace('Bearer ', '') // eslint-disable-next-line no-unused-vars - const { profile, scopes, unused } = lookUpByAuthCode(authCode) + const { profile, scopes, unused } = lookUpByAuthCode(authCode, { + isStateless, + }) const uuid = profile.uuid const nric = assertions.oidc.singPass.find((p) => p.uuid === uuid).nric const persona = assertions.myinfo.v3.personas[nric]