diff --git a/lib/express/oidc/v2-ndi.js b/lib/express/oidc/v2-ndi.js index bdbcf40..2a6e58a 100644 --- a/lib/express/oidc/v2-ndi.js +++ b/lib/express/oidc/v2-ndi.js @@ -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') @@ -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] @@ -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}`, @@ -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', @@ -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}`, @@ -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', }) @@ -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', }) @@ -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', }) @@ -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, @@ -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 } diff --git a/static/certs/oidc-v2-asp-public.json b/static/certs/oidc-v2-asp-public.json index 7334e49..a49f72c 100644 --- a/static/certs/oidc-v2-asp-public.json +++ b/static/certs/oidc-v2-asp-public.json @@ -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" + } ] } diff --git a/static/certs/oidc-v2-asp-secret.json b/static/certs/oidc-v2-asp-secret.json index a14d3bd..2152a7a 100644 --- a/static/certs/oidc-v2-asp-secret.json +++ b/static/certs/oidc-v2-asp-secret.json @@ -8,6 +8,15 @@ "kid": "sig-1655709297", "x": "AWuSHLkeP89DOkPaTs6MUDTFX1oL_Nr2rsJxCUyWL9x4LDEwtGXxWmw5-KhJSKauwJL2fAiNribZa2E0EZ-A4DzL", "y": "AHoghl5OGyp7Vejt2sqYW7z2G_gTGBDR9q-ylLjnERpKd7-kHybLEutkwp5tmkhhlOysCcXE7vpTcnwxeQPa3zN0" - } + }, + { + "kty": "EC", + "d": "_nXJySWym8zFj_jL3skM2zf0wxL8GQo10WgC3nrx3vw", + "use": "sig", + "crv": "P-256", + "kid": "ndi_mock_01", + "x": "ZyAP_T3GS6tzdEfIKgj7Z_TkKWQ9AQAU7LNTSV_JICQ", + "y": "gxQgPvGD8ASZT7DT41pgWP4ZHiZ_7HGcMoDM0NEOfO8" + } ] } \ No newline at end of file