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

refactor(ndi): use jose #595

Merged
merged 1 commit into from
Oct 6, 2023
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
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
Loading