Skip to content

Commit

Permalink
feat: allow stateless operation with base64url-encoded tokens (#618)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
LoneRifle authored Feb 8, 2024
1 parent 4d4bd6b commit 18e2eb7
Show file tree
Hide file tree
Showing 7 changed files with 61 additions and 25 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ Common configuration:
| Configuration item | Explanation |
|---|---|
| Port number | **Overview:** What port number MockPass will listen for HTTP requests on. <br> **Default:** 5156. <br> **How to configure:** Set the env var `MOCKPASS_PORT` or `PORT` to some port number. |
| Stateless Mode | **Overview:** Enable for environments where the state of the process is not guaranteed, such as in serverless contexts. <br> **Default:** not set. <br> **How to configure:** Set the env var `MOCKPASS_STATELESS` to `true` or `false`. |

Run MockPass:

Expand Down
5 changes: 4 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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')
Expand Down
18 changes: 14 additions & 4 deletions lib/auth-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
4 changes: 2 additions & 2 deletions lib/express/myinfo/consent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 20 additions & 8 deletions lib/express/oidc/spcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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 }
Expand All @@ -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}`,
Expand All @@ -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)
})
Expand All @@ -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')
Expand Down
10 changes: 5 additions & 5 deletions lib/express/oidc/v2-ndi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 }
Expand All @@ -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}`,
Expand All @@ -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)
})
Expand Down Expand Up @@ -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']
Expand Down
20 changes: 15 additions & 5 deletions lib/express/sgid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 }
Expand All @@ -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}`,
Expand All @@ -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}`,
)
Expand Down Expand Up @@ -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]
Expand Down

0 comments on commit 18e2eb7

Please sign in to comment.