Skip to content

Commit

Permalink
Merge pull request #595 from justin-tay/ndijose
Browse files Browse the repository at this point in the history
refactor(ndi): use jose
  • Loading branch information
LoneRifle authored Oct 6, 2023
2 parents 739976c + 08bfcac commit fc5bfef
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 46 deletions.
244 changes: 200 additions & 44 deletions lib/express/oidc/v2-ndi.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
const express = require('express')
const fs = require('fs')
const { render } = require('mustache')
const jose = require('node-jose')
const jose = require('jose')
const path = require('path')

const assertions = require('../../assertions')
Expand Down Expand Up @@ -32,6 +32,81 @@ const rpPublic = fs.readFileSync(
path.resolve(__dirname, '../../../static/certs/oidc-v2-rp-public.json'),
)

const singpass_token_endpoint_auth_signing_alg_values_supported = [
'ES256',
'ES384',
'ES512',
]

const corppass_token_endpoint_auth_signing_alg_values_supported = ['ES256']

const token_endpoint_auth_signing_alg_values_supported = {
singPass: singpass_token_endpoint_auth_signing_alg_values_supported,
corpPass: corppass_token_endpoint_auth_signing_alg_values_supported,
}

const singpass_id_token_encryption_alg_values_supported = [
'ECDH-ES+A256KW',
'ECDH-ES+A192KW',
'ECDH-ES+A128KW',
'RSA-OAEP-256',
]

const corppass_id_token_encryption_alg_values_supported = ['ECDH-ES+A256KW']

const id_token_encryption_alg_values_supported = {
singPass: singpass_id_token_encryption_alg_values_supported,
corpPass: corppass_id_token_encryption_alg_values_supported,
}

function findEncryptionKey(jwks) {
let encryptionKey = jwks.keys.find(
(item) =>
item.use === 'enc' &&
item.kty === 'EC' &&
item.crv === 'P-521' &&
(item.alg === 'ECDH-ES+A256KW' || !item.alg),
)
if (encryptionKey) {
return { ...encryptionKey, alg: 'ECDH-ES+A256KW' }
}
if (!encryptionKey) {
encryptionKey = jwks.keys.find(
(item) =>
item.use === 'enc' &&
item.kty === 'EC' &&
item.crv === 'P-384' &&
(item.alg === 'ECDH-ES+A192KW' || !item.alg),
)
}
if (encryptionKey) {
return { ...encryptionKey, alg: 'ECDH-ES+A192KW' }
}
if (!encryptionKey) {
encryptionKey = jwks.keys.find(
(item) =>
item.use === 'enc' &&
item.kty === 'EC' &&
item.crv === 'P-256' &&
(item.alg === 'ECDH-ES+A128KW' || !item.alg),
)
}
if (encryptionKey) {
return { ...encryptionKey, alg: 'ECDH-ES+A128KW' }
}
if (!encryptionKey) {
encryptionKey = jwks.keys.find(
(item) =>
item.use === 'enc' &&
item.kty === 'RSA' &&
(item.alg === 'RSA-OAEP-256' || !item.alg),
)
}
if (encryptionKey) {
return { ...encryptionKey, alg: 'RSA-OAEP-256' }
}
}

function config(app, { showLoginPage }) {
for (const idp of ['singPass', 'corpPass']) {
const profiles = assertions.oidc[idp]
Expand Down Expand Up @@ -146,18 +221,21 @@ function config(app, { showLoginPage }) {

// Only SP requires client_id
if (idp === 'singPass' && !client_id) {
console.error('Missing client_id')
return res.status(400).send({
error: 'invalid_request',
error_description: 'Missing client_id',
})
}
if (!redirectURI) {
console.error('Missing redirect_uri')
return res.status(400).send({
error: 'invalid_request',
error_description: 'Missing redirect_uri',
})
}
if (grant_type !== 'authorization_code') {
console.error('Unknown grant_type', grant_type)
return res.status(400).send({
error: 'unsupported_grant_type',
error_description: `Unknown grant_type ${grant_type}`,
Expand All @@ -173,12 +251,14 @@ function config(app, { showLoginPage }) {
client_assertion_type !==
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
) {
console.error('Unknown client_assertion_type', client_assertion_type)
return res.status(400).send({
error: 'invalid_request',
error_description: `Unknown client_assertion_type ${client_assertion_type}`,
})
}
if (!clientAssertion) {
console.error('Missing client_assertion')
return res.status(400).send({
error: 'invalid_request',
error_description: 'Missing client_assertion',
Expand All @@ -195,10 +275,19 @@ function config(app, { showLoginPage }) {

if (rpJwksEndpoint) {
try {
rpKeysetString = await fetch(rpJwksEndpoint, {
const rpKeysetResponse = await fetch(rpJwksEndpoint, {
method: 'GET',
}).then((response) => response.text())
})
rpKeysetString = await rpKeysetResponse.text()
if (!rpKeysetResponse.ok) {
throw new Error(rpKeysetString)
}
} catch (e) {
console.error(
'Failed to fetch RP JWKS from',
rpJwksEndpoint,
e.message,
)
return res.status(400).send({
error: 'invalid_client',
error_description: `Failed to fetch RP JWKS from specified endpoint: ${e.message}`,
Expand All @@ -213,40 +302,70 @@ function config(app, { showLoginPage }) {
try {
rpKeysetJson = JSON.parse(rpKeysetString)
} catch (e) {
console.error('Unable to parse RP keyset', e.message)
return res.status(400).send({
error: 'invalid_client',
error_description: `Unable to parse RP keyset: ${e.message}`,
})
}

const rpKeyset = await jose.JWK.asKeyStore(rpKeysetJson)

const rpKeyset = jose.createLocalJWKSet(rpKeysetJson)
// Step 0.5: Verify client assertion with RP signing key
let clientAssertionVerified
let clientAssertionResult
try {
clientAssertionVerified = await jose.JWS.createVerify(
clientAssertionResult = await jose.jwtVerify(
clientAssertion,
rpKeyset,
).verify(clientAssertion)
)
} catch (e) {
return res.status(400).send({
console.error(
'Unable to verify client_assertion',
e.message,
clientAssertion,
)
return res.status(401).send({
error: 'invalid_client',
error_description: `Unable to verify client_assertion: ${e.message}`,
})
}

let clientAssertionClaims
try {
clientAssertionClaims = JSON.parse(clientAssertionVerified.payload)
} catch (e) {
return res.status(400).send({
const { payload: clientAssertionClaims, protectedHeader } =
clientAssertionResult
console.debug(
'Received client_assertion',
clientAssertionClaims,
protectedHeader,
)
if (
!token_endpoint_auth_signing_alg_values_supported[idp].some(
(item) => item === protectedHeader.alg,
)
) {
console.warn(
'The client_assertion alg',
protectedHeader.alg,
'does not meet required token_endpoint_auth_signing_alg_values_supported',
token_endpoint_auth_signing_alg_values_supported[idp],
)
}

if (!protectedHeader.typ) {
console.error('The client_assertion typ should be set')
return res.status(401).send({
error: 'invalid_client',
error_description: `Unable to parse client_assertion: ${e.message}`,
error_description: 'The client_assertion typ should be set',
})
}

if (idp === 'singPass') {
if (clientAssertionClaims['sub'] !== client_id) {
return res.status(400).send({
console.error(
'Incorrect sub in client_assertion claims. Found',
clientAssertionClaims['sub'],
'but should be',
client_id,
)
return res.status(401).send({
error: 'invalid_client',
error_description: 'Incorrect sub in client_assertion claims',
})
Expand All @@ -255,7 +374,8 @@ function config(app, { showLoginPage }) {
// Since client_id is not given for corpPass, sub claim is required in
// order to get aud for id_token.
if (!clientAssertionClaims['sub']) {
return res.status(400).send({
console.error('Missing sub in client_assertion claims')
return res.status(401).send({
error: 'invalid_client',
error_description: 'Missing sub in client_assertion claims',
})
Expand All @@ -268,7 +388,13 @@ function config(app, { showLoginPage }) {
)}/${idp.toLowerCase()}/v2`

if (clientAssertionClaims['aud'] !== iss) {
return res.status(400).send({
console.error(
'Incorrect aud in client_assertion claims. Found',
clientAssertionClaims['aud'],
'but should be',
iss,
)
return res.status(401).send({
error: 'invalid_client',
error_description: 'Incorrect aud in client_assertion claims',
})
Expand All @@ -279,38 +405,65 @@ function config(app, { showLoginPage }) {

// Step 2: Get ID token
const aud = clientAssertionClaims['sub']
console.warn(
`Received auth code ${authCode} from ${aud} and ${redirectURI}`,
)
console.debug('Received token request', {
code: authCode,
client_id: aud,
redirect_uri: redirectURI,
})

const { idTokenClaims, accessToken } = await assertions.oidc.create[
idp
](profile, iss, aud, nonce)

// Step 3: Sign ID token with ASP signing key
const signingKey = await jose.JWK.asKeyStore(
JSON.parse(aspSecret),
).then((keystore) => keystore.get({ use: 'sig' }))

const signedIdToken = await jose.JWS.createSign(
{ format: 'compact' },
signingKey,
const aspKeyset = JSON.parse(aspSecret)
const aspSigningKey = aspKeyset.keys.find(
(item) =>
item.use === 'sig' && item.kty === 'EC' && item.crv === 'P-256',
)
if (!aspSigningKey) {
console.error('No suitable signing key found', aspKeyset.keys)
return res.status(400).send({
error: 'invalid_request',
error_description: 'No suitable signing key found',
})
}
const signingKey = await jose.importJWK(aspSigningKey, 'ES256')
const signedProtectedHeader = {
alg: 'ES256',
typ: 'JWT',
kid: signingKey.kid,
}
const signedIdToken = await new jose.CompactSign(
new TextEncoder().encode(JSON.stringify(idTokenClaims)),
)
.update(JSON.stringify(idTokenClaims))
.final()
.setProtectedHeader(signedProtectedHeader)
.sign(signingKey)

// Step 4: Encrypt ID token with RP encryption key
// We're using the first encryption key we find, although NDI actually
// has its own selection criteria.
const encryptionKey = rpKeyset.get({ use: 'enc' })

const idToken = await jose.JWE.createEncrypt(
{ format: 'compact', fields: { cty: 'JWT' } },
encryptionKey,
const rpEncryptionKey = findEncryptionKey(rpKeysetJson)
if (!rpEncryptionKey) {
console.error('No suitable encryption key found', rpKeysetJson.keys)
return res.status(400).send({
error: 'invalid_request',
error_description: 'No suitable encryption key found',
})
}
console.debug('Using encryption key', rpEncryptionKey)
const encryptedProtectedHeader = {
alg: rpEncryptionKey.alg,
typ: 'JWT',
kid: rpEncryptionKey.kid,
enc: 'A256CBC-HS512',
cty: 'JWT',
}
const idToken = await new jose.CompactEncrypt(
new TextEncoder().encode(signedIdToken),
)
.update(signedIdToken)
.final()
.setProtectedHeader(encryptedProtectedHeader)
.encrypt(await jose.importJWK(rpEncryptionKey, rpEncryptionKey.alg))

console.debug('ID Token', idToken)
// Step 5: Send token
res.status(200).send({
access_token: accessToken,
Expand Down Expand Up @@ -342,20 +495,23 @@ function config(app, { showLoginPage }) {
grant_types_supported: ['authorization_code'],
token_endpoint: `${baseUrl}/token`,
token_endpoint_auth_methods_supported: ['private_key_jwt'],
token_endpoint_auth_signing_alg_values_supported: ['ES512'], // omits ES256 and ES384 (allowed in SP)
token_endpoint_auth_signing_alg_values_supported:
token_endpoint_auth_signing_alg_values_supported[idp],
id_token_signing_alg_values_supported: ['ES256'],
id_token_encryption_alg_values_supported: ['ECDH-ES+A256KW'], // omits ECDH-ES+A192KW, ECDH-ES+A128KW and RSA-OAEP-256 (allowed in SP)
id_token_encryption_alg_values_supported:
id_token_encryption_alg_values_supported[idp],
id_token_encryption_enc_values_supported: ['A256CBC-HS512'],
}

if (idp === 'corpPass') {
data['claims_supported'].push([
data['claims_supported'] = [
...data['claims_supported'],
'userInfo',
'entityInfo',
'EntityInfo',
'rt_hash',
'at_hash',
'amr',
])
]
// Omit authorization-info_endpoint for CP
}

Expand Down
11 changes: 10 additions & 1 deletion static/certs/oidc-v2-asp-public.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
"kid": "sig-1655709297",
"x": "AWuSHLkeP89DOkPaTs6MUDTFX1oL_Nr2rsJxCUyWL9x4LDEwtGXxWmw5-KhJSKauwJL2fAiNribZa2E0EZ-A4DzL",
"y": "AHoghl5OGyp7Vejt2sqYW7z2G_gTGBDR9q-ylLjnERpKd7-kHybLEutkwp5tmkhhlOysCcXE7vpTcnwxeQPa3zN0"
}
},
{
"kty": "EC",
"use": "sig",
"crv": "P-256",
"kid": "ndi_mock_01",
"x": "ZyAP_T3GS6tzdEfIKgj7Z_TkKWQ9AQAU7LNTSV_JICQ",
"y": "gxQgPvGD8ASZT7DT41pgWP4ZHiZ_7HGcMoDM0NEOfO8",
"alg": "ES256"
}
]
}
Loading

0 comments on commit fc5bfef

Please sign in to comment.