Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow stateless operation with base64url-encoded tokens #618

Merged
merged 1 commit into from
Feb 8, 2024
Merged
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
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
Loading