From 46a0f16f139c021f3a9bcb847ad5baf65e318cc5 Mon Sep 17 00:00:00 2001 From: Chris Turner <23338096+christopherjturner@users.noreply.github.com> Date: Sat, 25 Nov 2023 22:19:19 +0000 Subject: [PATCH 1/6] inital port of OIDC mock --- package-lock.json | 75 ++++++++ package.json | 1 + .../oidc/controllers/authorize-controller.js | 61 +++++++ src/api/oidc/controllers/token-controller.js | 162 ++++++++++++++++++ src/api/oidc/controllers/well-known-jwks.js | 10 ++ .../well-known-openid-configuration.js | 31 ++++ src/api/oidc/helpers/oidc-crypto.js | 113 ++++++++++++ src/api/oidc/helpers/session-store.js | 37 ++++ src/api/oidc/helpers/user-defaults.js | 28 +++ src/api/oidc/helpers/validate-scope.js | 8 + src/api/oidc/index.js | 37 ++++ src/api/oidc/oidc.js | 68 ++++++++ src/api/router.js | 3 +- 13 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 src/api/oidc/controllers/authorize-controller.js create mode 100644 src/api/oidc/controllers/token-controller.js create mode 100644 src/api/oidc/controllers/well-known-jwks.js create mode 100644 src/api/oidc/controllers/well-known-openid-configuration.js create mode 100644 src/api/oidc/helpers/oidc-crypto.js create mode 100644 src/api/oidc/helpers/session-store.js create mode 100644 src/api/oidc/helpers/user-defaults.js create mode 100644 src/api/oidc/helpers/validate-scope.js create mode 100644 src/api/oidc/index.js create mode 100644 src/api/oidc/oidc.js diff --git a/package-lock.json b/package-lock.json index 6fbcdbd..334a055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "mongodb": "6.0.0", "node-fetch": "2.7.0", "node-jose": "2.2.0", + "pem-jwk": "2.0.0", "pino": "8.15.0" }, "devDependencies": { @@ -4953,6 +4954,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -5437,6 +5449,11 @@ "node": ">=8" } }, + "node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -10393,6 +10410,11 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -11144,6 +11166,20 @@ "node": ">=4" } }, + "node_modules/pem-jwk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pem-jwk/-/pem-jwk-2.0.0.tgz", + "integrity": "sha512-rFxu7rVoHgQ5H9YsP50dDWf0rHjreVA2z0yPiWr5WdH/UHb29hKtF7h6l8vNd1cbYR1t0QL+JKhW55a2ZV4KtA==", + "dependencies": { + "asn1.js": "^5.0.1" + }, + "bin": { + "pem-jwk": "bin/pem-jwk.js" + }, + "engines": { + "node": ">=5.10.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -11942,6 +11978,11 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -17095,6 +17136,17 @@ "is-string": "^1.0.7" } }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -17431,6 +17483,11 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, "bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -21039,6 +21096,11 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -21579,6 +21641,14 @@ } } }, + "pem-jwk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pem-jwk/-/pem-jwk-2.0.0.tgz", + "integrity": "sha512-rFxu7rVoHgQ5H9YsP50dDWf0rHjreVA2z0yPiWr5WdH/UHb29hKtF7h6l8vNd1cbYR1t0QL+JKhW55a2ZV4KtA==", + "requires": { + "asn1.js": "^5.0.1" + } + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -22135,6 +22205,11 @@ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==" }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", diff --git a/package.json b/package.json index 4402008..46d51f8 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "mongodb": "6.0.0", "node-fetch": "2.7.0", "node-jose": "2.2.0", + "pem-jwk": "2.0.0", "pino": "8.15.0" }, "devDependencies": { diff --git a/src/api/oidc/controllers/authorize-controller.js b/src/api/oidc/controllers/authorize-controller.js new file mode 100644 index 0000000..c5c9ba6 --- /dev/null +++ b/src/api/oidc/controllers/authorize-controller.js @@ -0,0 +1,61 @@ +import { oidcConfig } from '~/src/api/oidc/oidc' +import { validateScope } from '~/src/api/oidc/helpers/validate-scope' +import { newSession } from '~/src/api/oidc/helpers/session-store' + +const authorizeController = { + handler: (request, h) => { + // TODO check these are all set, use joi or something + const clientId = request.query.client_id + const responseType = request.query.response_type + const redirectUri = request.query.redirect_uri + const state = request.query.state + const scope = request.query.scope + const codeChallengeMethod = request.query.code_challenge_method + + // validate request + const unsupportedScopes = validateScope(scope) + if (unsupportedScopes.length > 0) { + return h + .response(`Unsupported scopes ${unsupportedScopes.join(',')}`) + .code(400) + } + + if (clientId !== oidcConfig.clientId) { + return h.response(`Invalid client id ${clientId}`).code(401) + } + + if (!oidcConfig.responseTypesSupported.includes(responseType)) { + return h.response(`Unsupported response type ${responseType}`).code(400) + } + + if ( + codeChallengeMethod && + !oidcConfig.codeChallengeMethodsSupported.includes(codeChallengeMethod) + ) { + return h + .response(`Unsupported code_challenge_method ${codeChallengeMethod}`) + .code(400) + } + + // create session + // TODO we should load the users from some sort of config + const user = { + id: '62bb35d2-d4f2-4cf6-abd3-262d99727677', // TODO: what should this be, refer to example configs + username: 'Test User' + } + const session = newSession( + scope, + request.query.nonce, + user, + request.query.code_challenge, + request.query.code_challenge_method + ) + + const location = new URL(redirectUri) + location.searchParams.append('code', session.sessionId) + location.searchParams.append('state', state) + return h.redirect(location.toString()) + } +} + +export { authorizeController } diff --git a/src/api/oidc/controllers/token-controller.js b/src/api/oidc/controllers/token-controller.js new file mode 100644 index 0000000..6c3f881 --- /dev/null +++ b/src/api/oidc/controllers/token-controller.js @@ -0,0 +1,162 @@ +import { keyPair, oidcConfig } from '~/src/api/oidc/oidc' +import { + getSessionByToken, + sessions +} from '~/src/api/oidc/helpers/session-store' +import { + generateCodeChallenge, + generateIDToken, + generateRefreshToken, + generateToken +} from '~/src/api/oidc/helpers/oidc-crypto' + +const tokenController = { + handler: (request, h) => { + const logger = request.logger + + const clientId = request.payload.client_id + const clientSecret = request.payload.client_secret + const grantType = request.payload.grant_type + const code = request.payload.code + const refreshToken = request.payload.refresh_token + const codeVerifier = request.payload.code_verifier + + // validate client id + if (clientId !== oidcConfig.clientId) { + logger.error(`Invalid client id ${clientId}`) + return h.response(`Invalid client id ${clientId}`).code(401) + } + + // validate secret + if (clientSecret !== oidcConfig.clientSecret) { + logger.error(`Invalid client secret`) + return h.response(`Invalid client secret`).code(401) + } + + // get the session depending on the grant type + let result = null + + if (grantType === 'authorization_code') { + logger.info('handling authorization code') + result = getSessionForAuthorizationCode(code) + // TODO: validate code challenge + const { valid, err } = validateCodeChallenge(result.session, codeVerifier) + if (!valid) { + logger.error(err) + return h.response(err).code(401) + } + } else if (grantType === 'refresh_token') { + logger.info('handling refresh token code') + result = getSessionForRefreshToken(refreshToken) + } else { + return h.response(`invalid grant type ${grantType}`).code(400) + } + + const { session, valid } = result + + if (!valid) { + logger.error('session was missing or invalid') + return h + .response(`invalid code/token for grant type ${grantType}`) + .code(400) + } + + // build the token response + + const tokenResponse = { + token_type: 'bearer', + expires_in: oidcConfig.ttl + } + + // copy over the refresh token if its not there + if (refreshToken && refreshToken !== 'null') { + logger.info(`refresh token ${refreshToken}`) + tokenResponse.refresh_token = refreshToken + } + + tokenResponse.access_token = generateToken(keyPair, session, grantType) + + // the example only checked the first item in the scope, unsure if this is correct or an oversight? + if (session.scopes[0] === 'openid') { + tokenResponse.id_token = generateIDToken(keyPair, session) + } + + if (grantType !== 'refresh_token') { + logger.info('generating a refresh token') + tokenResponse.refresh_token = generateRefreshToken(keyPair, session) + } + + logger.info(tokenResponse) + + return h + .response(tokenResponse) + .header('Cache-Control', 'no-cache, no-store, must-revalidate') + .code(200) + } +} + +function getSessionForAuthorizationCode(code) { + const session = sessions[code] + if (!session || session.granted) { + return { session: null, valid: false } + } + sessions[code].granted = true + return { + session, + valid: true + } +} + +function getSessionForRefreshToken(refreshToken) { + const session = getSessionByToken(refreshToken) + return { + session, + valid: session !== undefined + } +} + +function validateCodeChallenge(session, codeVerifier) { + if ( + session.codeChallenge === undefined || + session.codeChallenge === '' || + session.codeChallengeMethod === undefined || + session.codeChallengeMethod === '' + ) { + return { + valid: true, + err: '' + } + } + + if (codeVerifier === '') { + return { + valid: false, + err: 'Invalid code verifier. Expected code but client sent none.' + } + } + + const challenge = generateCodeChallenge( + session.codeChallengeMethod, + codeVerifier + ) + if (challenge === null) { + return { + valid: false, + err: `failed to generate code challenge for ${session.codeChallengeMethod} ${codeVerifier}` + } + } + + if (challenge !== session.codeChallenge) { + return { + valid: false, + err: 'Invalid code verifier. Code challenge did not match hashed code verifier.' + } + } + + return { + valid: true, + err: '' + } +} + +export { tokenController } diff --git a/src/api/oidc/controllers/well-known-jwks.js b/src/api/oidc/controllers/well-known-jwks.js new file mode 100644 index 0000000..4671e01 --- /dev/null +++ b/src/api/oidc/controllers/well-known-jwks.js @@ -0,0 +1,10 @@ +import { JWKS } from '~/src/api/oidc/helpers/oidc-crypto' +import { keyPair } from '~/src/api/oidc/oidc' + +const jwksController = { + handler: (request, h) => { + return h.response(JWKS(keyPair.publicKey)).code(200) + } +} + +export { jwksController } diff --git a/src/api/oidc/controllers/well-known-openid-configuration.js b/src/api/oidc/controllers/well-known-openid-configuration.js new file mode 100644 index 0000000..1c94a33 --- /dev/null +++ b/src/api/oidc/controllers/well-known-openid-configuration.js @@ -0,0 +1,31 @@ +import { oidcConfig } from '~/src/api/oidc/oidc' + +const generateConfig = () => { + return { + issuer: oidcConfig.issuerBase, + authorization_endpoint: oidcConfig.authorizationEndpoint, + pushed_authorization_request_endpoint: `${oidcConfig.issuerBase}/par`, + token_endpoint: oidcConfig.tokenEndpoint, + jwks_uri: oidcConfig.jwksEndpoint, + userinfo_endpoint: oidcConfig.userinfoEndpoint, + introspection_endpoint: `${oidcConfig.issuerBase}/introspect`, + grant_types_supported: oidcConfig.grantTypesSupported, + response_types_supported: oidcConfig.responseTypesSupported, + subject_types_supported: oidcConfig.subjectTypesSupported, + id_token_signing_alg_values_supported: + oidcConfig.idTokenSigningAlgValuesSupported, + scopes_supported: oidcConfig.scopesSupported, + token_endpoint_auth_methods_supported: + oidcConfig.tokenEndpointAuthMethodsSupported, + claims_supported: oidcConfig.claimsSupported, + code_challenge_methods_supported: oidcConfig.claimsSupported + } +} + +const openIdConfigurationController = { + handler: (request, h) => { + return h.response(generateConfig()).code(200) + } +} + +export { openIdConfigurationController } diff --git a/src/api/oidc/helpers/oidc-crypto.js b/src/api/oidc/helpers/oidc-crypto.js new file mode 100644 index 0000000..e49beb0 --- /dev/null +++ b/src/api/oidc/helpers/oidc-crypto.js @@ -0,0 +1,113 @@ +import * as crypto from 'crypto' +import { standardClaims } from '~/src/api/oidc/helpers/user-defaults' +import { keyId, oidcConfig } from '~/src/api/oidc/oidc' +import jsonwebtoken from 'jsonwebtoken' +import { jwk2pem } from 'pem-jwk' + +function generateRSAKeyPair() { + // TODO: load these from file so they remain the same between restarts + return crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, // 2048 bits is recommended for RSA keys + publicKeyEncoding: { + type: 'spki', + format: 'jwk' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'jwk' + } + }) +} + +function rsaKeyToJwk(key) { + return { + kty: 'RSA', + n: Buffer.from(key.n, 'base64').toString('base64url'), + e: Buffer.from(key.e, 'base64').toString('base64url'), + alg: 'RS256', + use: 'sig', + kid: keyId + } +} + +function JWKS(publicKey) { + return { + keys: [rsaKeyToJwk(publicKey)] + } +} + +function generateToken(keyPair, session) { + const privateKey = jwk2pem(keyPair.privateKey) + const claim = standardClaims(session, oidcConfig.ttl) + + return jsonwebtoken.sign(claim, privateKey, { + algorithm: 'RS256', + keyid: keyId + }) +} + +function generateIDToken(keyPair, session) { + const privateKey = jwk2pem(keyPair.privateKey) + const claims = standardClaims(session, oidcConfig.ttl) + claims.nonce = session.nonce + + // TODO: add claims for user here + return jsonwebtoken.sign(claims, privateKey, { + algorithm: 'RS256', + keyid: keyId + }) +} + +function generateRefreshToken(keyPair, session) { + const privateKey = jwk2pem(keyPair.privateKey) + + const claim = standardClaims(session, oidcConfig.refreshTtl) + return jsonwebtoken.sign(claim, privateKey, { + algorithm: 'RS256', + keyid: keyId + }) +} + +function generateCodeChallenge(method, codeVerifier) { + switch (method) { + case 'plain': + return codeVerifier + case 'S256': + return sha256(codeVerifier) + default: + return null + } +} + +// If not manually set, computes the JWT headers' `kid` +function keyID(keypair) { + const pem = jwk2pem(keypair.publicKey) + const publicKey = crypto.createPublicKey({ + key: pem, + format: 'pem', + type: 'spki' + }) + const publicKeyDER = publicKey.export({ type: 'spki', format: 'der' }) + + return sha256(publicKeyDER) +} + +function sha256(input) { + const sha256 = crypto.createHash('sha256').update(input).digest() + return sha256 + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +export { + generateRSAKeyPair, + rsaKeyToJwk, + JWKS, + generateToken, + generateIDToken, + generateRefreshToken, + generateCodeChallenge, + keyID +} diff --git a/src/api/oidc/helpers/session-store.js b/src/api/oidc/helpers/session-store.js new file mode 100644 index 0000000..7a59905 --- /dev/null +++ b/src/api/oidc/helpers/session-store.js @@ -0,0 +1,37 @@ +import * as crypto from 'crypto' +import jsonwebtoken from 'jsonwebtoken' +import { createLogger } from '~/src/helpers/logging/logger' + +const logger = createLogger() + +const sessions = {} + +function getSessionId() { + return crypto.randomUUID() +} + +function newSession(scope, nonce, user, challenge, challengeMethod) { + const id = getSessionId() + + sessions[id] = { + sessionId: id, + scopes: scope.split(' '), + oidcNonce: nonce, + user, + granted: false, + codeChallenge: challenge, + codeChallengeMethod: challengeMethod + } + + logger.info(`Creating a new session ${JSON.stringify(sessions[id])}`) + + return sessions[id] +} + +function getSessionByToken(token) { + const decodedToken = jsonwebtoken.decode(token) + const sessionId = decodedToken?.jti + return sessions[sessionId] +} + +export { sessions, newSession, getSessionByToken } diff --git a/src/api/oidc/helpers/user-defaults.js b/src/api/oidc/helpers/user-defaults.js new file mode 100644 index 0000000..19bb665 --- /dev/null +++ b/src/api/oidc/helpers/user-defaults.js @@ -0,0 +1,28 @@ +import { oidcConfig } from '~/src/api/oidc/oidc' + +function standardClaims(session, ttl) { + const now = Math.floor(Date.now() / 1000) + return { + aud: [oidcConfig.clientId], // Audience should this be an array? + exp: now + ttl, // expires at + jti: session.sessionId, // session id + iat: now, // issued at + iss: oidcConfig.issuerBase, // TODO: is this right or is it just the host, wireshark to find out i guess + nbf: now, // not before + sub: session.user.id, // subject (i.e. user) + amr: ['eid', 'urn:be:fedict:iam:fas:Level500'], + azp: '63983fc2-cfff-45bb-8ec2-959e21062b9a', // azure something? + azpacr: 1, + groups: ['aabe63e7-87ef-4beb-a596-c810631fc474'], + name: session.user.username, + preferred_username: 'test@user.com', + oid: '62bb35d2-d4f2-4cf6-abd3-262d99727677', // ?? + scope: 'openid read api', + upn: 'test@user.com', + uti: 'E_C59uEkm0ZORgAi0cAA', + ver: '2.0', + wids: ['13bd1c72-6f4a-4dcf-985f-18d3b80f208a'] + } +} + +export { standardClaims } diff --git a/src/api/oidc/helpers/validate-scope.js b/src/api/oidc/helpers/validate-scope.js new file mode 100644 index 0000000..89b2e2c --- /dev/null +++ b/src/api/oidc/helpers/validate-scope.js @@ -0,0 +1,8 @@ +import { oidcConfig } from '~/src/api/oidc/oidc' + +const validateScope = (scope) => { + const scopes = scope.split(' ') + return scopes.filter((s) => !oidcConfig.scopesSupported.includes(s)) +} + +export { validateScope } diff --git a/src/api/oidc/index.js b/src/api/oidc/index.js new file mode 100644 index 0000000..e8f7262 --- /dev/null +++ b/src/api/oidc/index.js @@ -0,0 +1,37 @@ +import { oidcBasePath } from '~/src/api/oidc/oidc' +import { openIdConfigurationController } from '~/src/api/oidc/controllers/well-known-openid-configuration' +import { jwksController } from '~/src/api/oidc/controllers/well-known-jwks' +import { authorizeController } from '~/src/api/oidc/controllers/authorize-controller' +import { tokenController } from '~/src/api/oidc/controllers/token-controller' + +const oidc = { + plugin: { + name: 'oidc', + register: async (server) => { + server.route([ + { + method: 'GET', + path: `${oidcBasePath}/.well-known/openid-configuration`, + ...openIdConfigurationController + }, + { + method: 'GET', + path: `${oidcBasePath}/.well-known/jwks.json`, + ...jwksController + }, + { + method: 'GET', + path: `${oidcBasePath}/authorize`, + ...authorizeController + }, + { + method: 'POST', + path: `${oidcBasePath}/token`, + ...tokenController + } + ]) + } + } +} + +export { oidc } diff --git a/src/api/oidc/oidc.js b/src/api/oidc/oidc.js new file mode 100644 index 0000000..0d61c48 --- /dev/null +++ b/src/api/oidc/oidc.js @@ -0,0 +1,68 @@ +import { generateRSAKeyPair, keyID } from '~/src/api/oidc/helpers/oidc-crypto' + +const serverHost = 'http://localhost:3939' +const oidcBasePath = '/oidc' + +const oidcConfig = { + clientId: '63983fc2-cfff-45bb-8ec2-959e21062b9a', + clientSecret: 'test_value', + issuerBase: `${serverHost}${oidcBasePath}`, + authorizationEndpoint: `${serverHost}${oidcBasePath}/authorize`, + tokenEndpoint: `${serverHost}${oidcBasePath}/token`, + userinfoEndpoint: `${serverHost}${oidcBasePath}/userinfo`, + jwksEndpoint: `${serverHost}${oidcBasePath}/.well-known/jwks.json`, + discoveryEndpoint: `${serverHost}${oidcBasePath}/.well-known/openid-configuration`, + + invalidRequest: 'invalid_request', + invalidClient: 'invalid_client', + invalidGrant: 'invalid_grant', + unsupportedGrantType: 'unsupported_grant_type', + invalidScope: 'invalid_scope', + internalServerError: 'internal_server_error', + openidScope: 'openid', + + grantTypesSupported: [ + 'authorization_code', + 'refresh_token', + 'client_credentials' + ], + responseTypesSupported: ['code'], + subjectTypesSupported: ['public'], + + idTokenSigningAlgValuesSupported: ['RS256'], + + scopesSupported: [ + 'openid', + 'email', + 'groups', + 'profile', + 'api://63983fc2-cfff-45bb-8ec2-959e21062b9a/cdp.user', + 'offline_access', + 'user.read' + ], + + tokenEndpointAuthMethodsSupported: [ + 'client_secret_basic', + 'client_secret_post' + ], + + claimsSupported: [ + 'sub', + 'email', + 'email_verified', + 'preferred_username', + 'phone_number', + 'address', + 'groups', + 'iss', + 'aud' + ], + codeChallengeMethodsSupported: ['plain', 'S256'], + ttl: 3600, // TODO: what unit are these? ms/sec? + refreshTtl: 3600 +} + +const keyPair = generateRSAKeyPair() +const keyId = keyID(keyPair) + +export { oidcConfig, oidcBasePath, keyPair, keyId } diff --git a/src/api/router.js b/src/api/router.js index a2a2d12..550748c 100644 --- a/src/api/router.js +++ b/src/api/router.js @@ -2,12 +2,13 @@ import { health } from '~/src/api/health' import { githubStub } from '~/src/api/github' import { ecrStub } from '~/src/api/ecr' import { adminStub } from '~/src/api/admin' +import { oidc } from '~/src/api/oidc' const router = { plugin: { name: 'Router', register: async (server) => { - await server.register([health, githubStub, ecrStub, adminStub]) + await server.register([health, githubStub, ecrStub, adminStub, oidc]) } } } From 05d86a70d15242efed0929302d3a5fb209ced252 Mon Sep 17 00:00:00 2001 From: Chris Turner <23338096+christopherjturner@users.noreply.github.com> Date: Sun, 26 Nov 2023 14:51:28 +0000 Subject: [PATCH 2/6] refactor key usage, user selection page --- .../oidc/controllers/authorize-controller.js | 36 +++++++-- src/api/oidc/controllers/login-controller.js | 14 ++++ src/api/oidc/controllers/token-controller.js | 56 ++------------ src/api/oidc/controllers/well-known-jwks.js | 3 +- .../well-known-openid-configuration.js | 2 +- src/api/oidc/helpers/default-claims.js | 28 +++++++ src/api/oidc/helpers/oidc-crypto.js | 73 ++++++++++--------- src/api/oidc/helpers/oidc-crypto.test.js | 31 ++++++++ src/api/oidc/helpers/user-defaults.js | 28 ------- src/api/oidc/helpers/users.js | 20 +++++ .../oidc/helpers/validate-code-challenge.js | 47 ++++++++++++ src/api/oidc/helpers/validate-scope.js | 2 +- src/api/oidc/index.js | 14 +++- src/api/oidc/{oidc.js => oidc-config.js} | 7 +- 14 files changed, 231 insertions(+), 130 deletions(-) create mode 100644 src/api/oidc/controllers/login-controller.js create mode 100644 src/api/oidc/helpers/default-claims.js create mode 100644 src/api/oidc/helpers/oidc-crypto.test.js delete mode 100644 src/api/oidc/helpers/user-defaults.js create mode 100644 src/api/oidc/helpers/users.js create mode 100644 src/api/oidc/helpers/validate-code-challenge.js rename src/api/oidc/{oidc.js => oidc-config.js} (89%) diff --git a/src/api/oidc/controllers/authorize-controller.js b/src/api/oidc/controllers/authorize-controller.js index c5c9ba6..e2c3c85 100644 --- a/src/api/oidc/controllers/authorize-controller.js +++ b/src/api/oidc/controllers/authorize-controller.js @@ -1,9 +1,32 @@ -import { oidcConfig } from '~/src/api/oidc/oidc' +import { oidcBasePath, oidcConfig } from '~/src/api/oidc/oidc-config' import { validateScope } from '~/src/api/oidc/helpers/validate-scope' import { newSession } from '~/src/api/oidc/helpers/session-store' +import { allUsers } from '~/src/api/oidc/helpers/users' const authorizeController = { handler: (request, h) => { + // a bit of a hack, but basically if the user propery hasn't been set + // show a 'login' page where they can select which fake user they want + if (request.query.user === undefined) { + const fullUrl = new URL(request.url) + const page = ` +

Login

+ ${Object.keys(allUsers).map( + (u) => + '' + + u + + '' + )} + ` + return h.response(page).header('content-type', 'text/html').code(200) + } + // TODO check these are all set, use joi or something const clientId = request.query.client_id const responseType = request.query.response_type @@ -37,12 +60,13 @@ const authorizeController = { .code(400) } - // create session - // TODO we should load the users from some sort of config - const user = { - id: '62bb35d2-d4f2-4cf6-abd3-262d99727677', // TODO: what should this be, refer to example configs - username: 'Test User' + const user = allUsers[request.query.user] + if (user === undefined) { + request.logger.error(`Invalid user selected ${request.query.user}`) + return h.response(`Invalid user selection!`).code(400) } + + // create session const session = newSession( scope, request.query.nonce, diff --git a/src/api/oidc/controllers/login-controller.js b/src/api/oidc/controllers/login-controller.js new file mode 100644 index 0000000..0ee4da2 --- /dev/null +++ b/src/api/oidc/controllers/login-controller.js @@ -0,0 +1,14 @@ +import { oidcBasePath } from '~/src/api/oidc/oidc-config' + +const loginController = { + handler: (request, h) => { + const fullUrl = new URL(request.url) + const page = ` +

Login

+ Admin + ` + return h.response(page).header('content-type', 'text/html').code(200) + } +} + +export { loginController } diff --git a/src/api/oidc/controllers/token-controller.js b/src/api/oidc/controllers/token-controller.js index 6c3f881..00e3998 100644 --- a/src/api/oidc/controllers/token-controller.js +++ b/src/api/oidc/controllers/token-controller.js @@ -1,14 +1,14 @@ -import { keyPair, oidcConfig } from '~/src/api/oidc/oidc' +import { oidcConfig } from '~/src/api/oidc/oidc-config' import { getSessionByToken, sessions } from '~/src/api/oidc/helpers/session-store' import { - generateCodeChallenge, generateIDToken, generateRefreshToken, generateToken } from '~/src/api/oidc/helpers/oidc-crypto' +import { validateCodeChallenge } from '~/src/api/oidc/helpers/validate-code-challenge' const tokenController = { handler: (request, h) => { @@ -39,7 +39,6 @@ const tokenController = { if (grantType === 'authorization_code') { logger.info('handling authorization code') result = getSessionForAuthorizationCode(code) - // TODO: validate code challenge const { valid, err } = validateCodeChallenge(result.session, codeVerifier) if (!valid) { logger.error(err) @@ -69,21 +68,22 @@ const tokenController = { } // copy over the refresh token if its not there + // unsure if we still need the !== string 'null' check anymore if (refreshToken && refreshToken !== 'null') { logger.info(`refresh token ${refreshToken}`) tokenResponse.refresh_token = refreshToken } - tokenResponse.access_token = generateToken(keyPair, session, grantType) + tokenResponse.access_token = generateToken(request.keys, session) // the example only checked the first item in the scope, unsure if this is correct or an oversight? if (session.scopes[0] === 'openid') { - tokenResponse.id_token = generateIDToken(keyPair, session) + tokenResponse.id_token = generateIDToken(request.keys, session) } if (grantType !== 'refresh_token') { logger.info('generating a refresh token') - tokenResponse.refresh_token = generateRefreshToken(keyPair, session) + tokenResponse.refresh_token = generateRefreshToken(request.keys, session) } logger.info(tokenResponse) @@ -115,48 +115,4 @@ function getSessionForRefreshToken(refreshToken) { } } -function validateCodeChallenge(session, codeVerifier) { - if ( - session.codeChallenge === undefined || - session.codeChallenge === '' || - session.codeChallengeMethod === undefined || - session.codeChallengeMethod === '' - ) { - return { - valid: true, - err: '' - } - } - - if (codeVerifier === '') { - return { - valid: false, - err: 'Invalid code verifier. Expected code but client sent none.' - } - } - - const challenge = generateCodeChallenge( - session.codeChallengeMethod, - codeVerifier - ) - if (challenge === null) { - return { - valid: false, - err: `failed to generate code challenge for ${session.codeChallengeMethod} ${codeVerifier}` - } - } - - if (challenge !== session.codeChallenge) { - return { - valid: false, - err: 'Invalid code verifier. Code challenge did not match hashed code verifier.' - } - } - - return { - valid: true, - err: '' - } -} - export { tokenController } diff --git a/src/api/oidc/controllers/well-known-jwks.js b/src/api/oidc/controllers/well-known-jwks.js index 4671e01..15c0dcd 100644 --- a/src/api/oidc/controllers/well-known-jwks.js +++ b/src/api/oidc/controllers/well-known-jwks.js @@ -1,9 +1,8 @@ import { JWKS } from '~/src/api/oidc/helpers/oidc-crypto' -import { keyPair } from '~/src/api/oidc/oidc' const jwksController = { handler: (request, h) => { - return h.response(JWKS(keyPair.publicKey)).code(200) + return h.response(JWKS(request.keys)).code(200) } } diff --git a/src/api/oidc/controllers/well-known-openid-configuration.js b/src/api/oidc/controllers/well-known-openid-configuration.js index 1c94a33..e779267 100644 --- a/src/api/oidc/controllers/well-known-openid-configuration.js +++ b/src/api/oidc/controllers/well-known-openid-configuration.js @@ -1,4 +1,4 @@ -import { oidcConfig } from '~/src/api/oidc/oidc' +import { oidcConfig } from '~/src/api/oidc/oidc-config' const generateConfig = () => { return { diff --git a/src/api/oidc/helpers/default-claims.js b/src/api/oidc/helpers/default-claims.js new file mode 100644 index 0000000..40d39db --- /dev/null +++ b/src/api/oidc/helpers/default-claims.js @@ -0,0 +1,28 @@ +import { oidcConfig } from '~/src/api/oidc/oidc-config' + +function defaultClaims(session, ttl) { + const now = Math.floor(Date.now() / 1000) + return { + aud: [oidcConfig.clientId], // Audience + exp: now + ttl, // expires at + jti: session.sessionId, // session id + iat: now, // issued at + iss: oidcConfig.issuerBase, // issuer + nbf: now, // not before + sub: session.user.id, // subject (i.e. user) + amr: ['eid', 'urn:be:fedict:iam:fas:Level500'], + azp: '63983fc2-cfff-45bb-8ec2-959e21062b9a', // azure something? + azpacr: 1, // more azure stuff? + groups: session.user.teams, + name: session.user.username, + preferred_username: session.user.email, + oid: session.user.id, // should this be user id or a unique one + scope: 'openid read api', + upn: session.user.email, + uti: 'E_C59uEkm0ZORgAi0cAA', // Token identifier claim, unique, per-token identifier that is case-sensitive + ver: '2.0', + wids: ['13bd1c72-6f4a-4dcf-985f-18d3b80f208a'] // ?? + } +} + +export { defaultClaims } diff --git a/src/api/oidc/helpers/oidc-crypto.js b/src/api/oidc/helpers/oidc-crypto.js index e49beb0..6f69747 100644 --- a/src/api/oidc/helpers/oidc-crypto.js +++ b/src/api/oidc/helpers/oidc-crypto.js @@ -1,9 +1,23 @@ import * as crypto from 'crypto' -import { standardClaims } from '~/src/api/oidc/helpers/user-defaults' -import { keyId, oidcConfig } from '~/src/api/oidc/oidc' +import { defaultClaims } from '~/src/api/oidc/helpers/default-claims' +import { oidcConfig } from '~/src/api/oidc/oidc-config' import jsonwebtoken from 'jsonwebtoken' import { jwk2pem } from 'pem-jwk' +function loadOIDCKeys() { + const jwk = generateRSAKeyPair() + const pem = { + publicKey: jwk2pem(jwk.publicKey), + privateKey: jwk2pem(jwk.privateKey) + } + const keyId = keyID(pem.publicKey) + return { + jwk, + keyId, + pem + } +} + function generateRSAKeyPair() { // TODO: load these from file so they remain the same between restarts return crypto.generateKeyPairSync('rsa', { @@ -19,52 +33,43 @@ function generateRSAKeyPair() { }) } -function rsaKeyToJwk(key) { - return { +function JWKS(keys) { + const jwks = { kty: 'RSA', - n: Buffer.from(key.n, 'base64').toString('base64url'), - e: Buffer.from(key.e, 'base64').toString('base64url'), + n: Buffer.from(keys.jwk.publicKey.n, 'base64').toString('base64url'), + e: Buffer.from(keys.jwk.publicKey.e, 'base64').toString('base64url'), alg: 'RS256', use: 'sig', - kid: keyId + kid: keys.keyId } -} -function JWKS(publicKey) { return { - keys: [rsaKeyToJwk(publicKey)] + keys: [jwks] } } -function generateToken(keyPair, session) { - const privateKey = jwk2pem(keyPair.privateKey) - const claim = standardClaims(session, oidcConfig.ttl) - - return jsonwebtoken.sign(claim, privateKey, { +function generateToken(keys, session) { + const claim = defaultClaims(session, oidcConfig.ttl) + return jsonwebtoken.sign(claim, keys.pem.privateKey, { algorithm: 'RS256', - keyid: keyId + keyid: keys.keyId }) } -function generateIDToken(keyPair, session) { - const privateKey = jwk2pem(keyPair.privateKey) - const claims = standardClaims(session, oidcConfig.ttl) - claims.nonce = session.nonce - - // TODO: add claims for user here - return jsonwebtoken.sign(claims, privateKey, { +function generateIDToken(keys, session) { + const claim = defaultClaims(session, oidcConfig.ttl) + claim.nonce = session.nonce + return jsonwebtoken.sign(claim, keys.pem.privateKey, { algorithm: 'RS256', - keyid: keyId + keyid: keys.keyId }) } -function generateRefreshToken(keyPair, session) { - const privateKey = jwk2pem(keyPair.privateKey) - - const claim = standardClaims(session, oidcConfig.refreshTtl) - return jsonwebtoken.sign(claim, privateKey, { +function generateRefreshToken(keys, session) { + const claim = defaultClaims(session, oidcConfig.refreshTtl) + return jsonwebtoken.sign(claim, keys.pem.privateKey, { algorithm: 'RS256', - keyid: keyId + keyid: keys.keyId }) } @@ -80,8 +85,7 @@ function generateCodeChallenge(method, codeVerifier) { } // If not manually set, computes the JWT headers' `kid` -function keyID(keypair) { - const pem = jwk2pem(keypair.publicKey) +function keyID(pem) { const publicKey = crypto.createPublicKey({ key: pem, format: 'pem', @@ -102,12 +106,11 @@ function sha256(input) { } export { - generateRSAKeyPair, - rsaKeyToJwk, + loadOIDCKeys, JWKS, generateToken, generateIDToken, generateRefreshToken, generateCodeChallenge, - keyID + sha256 } diff --git a/src/api/oidc/helpers/oidc-crypto.test.js b/src/api/oidc/helpers/oidc-crypto.test.js new file mode 100644 index 0000000..75e4979 --- /dev/null +++ b/src/api/oidc/helpers/oidc-crypto.test.js @@ -0,0 +1,31 @@ +import { + generateCodeChallenge, + sha256 +} from '~/src/api/oidc/helpers/oidc-crypto' + +describe('sha256', () => { + it('should produce a valid url-safe base 64 hash in', () => { + const hash = sha256('foobarbaz') + expect(hash).toBe('l981iLWj8kurw4UbNy8Lpxqdzd7UOxS50Glhv8FwfZ0') + + const hash2 = sha256('foobar') + expect(hash2).toBe('w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI') + }) +}) + +describe('generateCodeChallenge', () => { + it('should return the codeVerifier as plain text in plain mode', () => { + const result = generateCodeChallenge('plain', 'foobarbaz') + expect(result).toBe('foobarbaz') + }) + + it('should return the codeVerifier as a sha256 encoded codeVerifier in S256 mode', () => { + const result = generateCodeChallenge('S256', 'foobarbaz') + expect(result).toBe('l981iLWj8kurw4UbNy8Lpxqdzd7UOxS50Glhv8FwfZ0') + }) + + it('should return null when the mode is invalid', () => { + const result = generateCodeChallenge('foo', 'foobarbaz') + expect(result).toBeNull() + }) +}) diff --git a/src/api/oidc/helpers/user-defaults.js b/src/api/oidc/helpers/user-defaults.js deleted file mode 100644 index 19bb665..0000000 --- a/src/api/oidc/helpers/user-defaults.js +++ /dev/null @@ -1,28 +0,0 @@ -import { oidcConfig } from '~/src/api/oidc/oidc' - -function standardClaims(session, ttl) { - const now = Math.floor(Date.now() / 1000) - return { - aud: [oidcConfig.clientId], // Audience should this be an array? - exp: now + ttl, // expires at - jti: session.sessionId, // session id - iat: now, // issued at - iss: oidcConfig.issuerBase, // TODO: is this right or is it just the host, wireshark to find out i guess - nbf: now, // not before - sub: session.user.id, // subject (i.e. user) - amr: ['eid', 'urn:be:fedict:iam:fas:Level500'], - azp: '63983fc2-cfff-45bb-8ec2-959e21062b9a', // azure something? - azpacr: 1, - groups: ['aabe63e7-87ef-4beb-a596-c810631fc474'], - name: session.user.username, - preferred_username: 'test@user.com', - oid: '62bb35d2-d4f2-4cf6-abd3-262d99727677', // ?? - scope: 'openid read api', - upn: 'test@user.com', - uti: 'E_C59uEkm0ZORgAi0cAA', - ver: '2.0', - wids: ['13bd1c72-6f4a-4dcf-985f-18d3b80f208a'] - } -} - -export { standardClaims } diff --git a/src/api/oidc/helpers/users.js b/src/api/oidc/helpers/users.js new file mode 100644 index 0000000..3509a14 --- /dev/null +++ b/src/api/oidc/helpers/users.js @@ -0,0 +1,20 @@ +const userAdmin = { + username: 'admin', + preferred_username: 'admin@oidc.mock', + id: '90552794-0613-4023-819a-512aa9d40023', + teams: ['aabe63e7-87ef-4beb-a596-c810631fc474'] +} + +const userNonAdmin = { + username: 'Test User', + email: 'test.user@oidc.mock', + id: 'dfa791eb-76b2-434c-ad1f-bb9dc1dd8b48', + teams: ['aabe63e7-87ef-4beb-a596-c810631fc474'] // TODO: swap this for a non-admin team +} + +const allUsers = { + admin: userAdmin, + nonAdmin: userNonAdmin +} + +export { userAdmin, userNonAdmin, allUsers } diff --git a/src/api/oidc/helpers/validate-code-challenge.js b/src/api/oidc/helpers/validate-code-challenge.js new file mode 100644 index 0000000..b0b0ca2 --- /dev/null +++ b/src/api/oidc/helpers/validate-code-challenge.js @@ -0,0 +1,47 @@ +import { generateCodeChallenge } from '~/src/api/oidc/helpers/oidc-crypto' + +function validateCodeChallenge(session, codeVerifier) { + if ( + session.codeChallenge === undefined || + session.codeChallenge === '' || + session.codeChallengeMethod === undefined || + session.codeChallengeMethod === '' + ) { + return { + valid: true, + err: '' + } + } + + if (codeVerifier === '') { + return { + valid: false, + err: 'Invalid code verifier. Expected code but client sent none.' + } + } + + const challenge = generateCodeChallenge( + session.codeChallengeMethod, + codeVerifier + ) + if (challenge === null) { + return { + valid: false, + err: `failed to generate code challenge for ${session.codeChallengeMethod} ${codeVerifier}` + } + } + + if (challenge !== session.codeChallenge) { + return { + valid: false, + err: 'Invalid code verifier. Code challenge did not match hashed code verifier.' + } + } + + return { + valid: true, + err: '' + } +} + +export { validateCodeChallenge } diff --git a/src/api/oidc/helpers/validate-scope.js b/src/api/oidc/helpers/validate-scope.js index 89b2e2c..a437825 100644 --- a/src/api/oidc/helpers/validate-scope.js +++ b/src/api/oidc/helpers/validate-scope.js @@ -1,4 +1,4 @@ -import { oidcConfig } from '~/src/api/oidc/oidc' +import { oidcConfig } from '~/src/api/oidc/oidc-config' const validateScope = (scope) => { const scopes = scope.split(' ') diff --git a/src/api/oidc/index.js b/src/api/oidc/index.js index e8f7262..67ece5b 100644 --- a/src/api/oidc/index.js +++ b/src/api/oidc/index.js @@ -1,13 +1,20 @@ -import { oidcBasePath } from '~/src/api/oidc/oidc' +import { oidcBasePath } from '~/src/api/oidc/oidc-config' import { openIdConfigurationController } from '~/src/api/oidc/controllers/well-known-openid-configuration' import { jwksController } from '~/src/api/oidc/controllers/well-known-jwks' import { authorizeController } from '~/src/api/oidc/controllers/authorize-controller' import { tokenController } from '~/src/api/oidc/controllers/token-controller' +import { loadOIDCKeys } from '~/src/api/oidc/helpers/oidc-crypto' +import { loginController } from '~/src/api/oidc/controllers/login-controller' const oidc = { plugin: { name: 'oidc', register: async (server) => { + // generate/load the keys and decorate the server/request with them + const keys = loadOIDCKeys() + server.decorate('server', 'keys', keys) + server.decorate('request', 'keys', keys) + server.route([ { method: 'GET', @@ -24,6 +31,11 @@ const oidc = { path: `${oidcBasePath}/authorize`, ...authorizeController }, + { + method: 'GET', + path: `${oidcBasePath}/login`, + ...loginController + }, { method: 'POST', path: `${oidcBasePath}/token`, diff --git a/src/api/oidc/oidc.js b/src/api/oidc/oidc-config.js similarity index 89% rename from src/api/oidc/oidc.js rename to src/api/oidc/oidc-config.js index 0d61c48..30e9c1f 100644 --- a/src/api/oidc/oidc.js +++ b/src/api/oidc/oidc-config.js @@ -1,5 +1,3 @@ -import { generateRSAKeyPair, keyID } from '~/src/api/oidc/helpers/oidc-crypto' - const serverHost = 'http://localhost:3939' const oidcBasePath = '/oidc' @@ -62,7 +60,4 @@ const oidcConfig = { refreshTtl: 3600 } -const keyPair = generateRSAKeyPair() -const keyId = keyID(keyPair) - -export { oidcConfig, oidcBasePath, keyPair, keyId } +export { oidcConfig, oidcBasePath } From ccc4a45a65d1b88cb7eed116d0c53522b8e65057 Mon Sep 17 00:00:00 2001 From: Chris Turner <23338096+christopherjturner@users.noreply.github.com> Date: Mon, 27 Nov 2023 07:12:36 +0000 Subject: [PATCH 3/6] user-info endpoint --- .../oidc/controllers/authorize-controller.js | 21 +++++++----------- src/api/oidc/controllers/login-controller.js | 14 ------------ .../oidc/controllers/user-info-controller.js | 22 +++++++++++++++++++ src/api/oidc/helpers/oidc-crypto.js | 2 -- src/api/oidc/index.js | 12 +++++----- 5 files changed, 36 insertions(+), 35 deletions(-) delete mode 100644 src/api/oidc/controllers/login-controller.js create mode 100644 src/api/oidc/controllers/user-info-controller.js diff --git a/src/api/oidc/controllers/authorize-controller.js b/src/api/oidc/controllers/authorize-controller.js index e2c3c85..8fad9bf 100644 --- a/src/api/oidc/controllers/authorize-controller.js +++ b/src/api/oidc/controllers/authorize-controller.js @@ -3,26 +3,21 @@ import { validateScope } from '~/src/api/oidc/helpers/validate-scope' import { newSession } from '~/src/api/oidc/helpers/session-store' import { allUsers } from '~/src/api/oidc/helpers/users' +const userToLink = (user, query) => { + return `${user}` +} + const authorizeController = { handler: (request, h) => { - // a bit of a hack, but basically if the user propery hasn't been set + // a bit of a hack, but basically if the user param hasn't been set // show a 'login' page where they can select which fake user they want if (request.query.user === undefined) { const fullUrl = new URL(request.url) const page = `

Login

- ${Object.keys(allUsers).map( - (u) => - '' + - u + - '' - )} + ${Object.keys(allUsers) + .map((user) => userToLink(user, fullUrl.search)) + .join('
')} ` return h.response(page).header('content-type', 'text/html').code(200) } diff --git a/src/api/oidc/controllers/login-controller.js b/src/api/oidc/controllers/login-controller.js deleted file mode 100644 index 0ee4da2..0000000 --- a/src/api/oidc/controllers/login-controller.js +++ /dev/null @@ -1,14 +0,0 @@ -import { oidcBasePath } from '~/src/api/oidc/oidc-config' - -const loginController = { - handler: (request, h) => { - const fullUrl = new URL(request.url) - const page = ` -

Login

- Admin - ` - return h.response(page).header('content-type', 'text/html').code(200) - } -} - -export { loginController } diff --git a/src/api/oidc/controllers/user-info-controller.js b/src/api/oidc/controllers/user-info-controller.js new file mode 100644 index 0000000..f9e2dd3 --- /dev/null +++ b/src/api/oidc/controllers/user-info-controller.js @@ -0,0 +1,22 @@ +import jsonwebtoken from 'jsonwebtoken' + +const userInfoController = { + handler: (request, h) => { + const authHeader = request.headers.authorization + if (authHeader === undefined || !authHeader.startsWith('Bearer ')) { + return h.response('Missing bearer token').code(401) + } + // drop 'Bearer ' + const token = authHeader.slice(7) + + try { + const decoded = jsonwebtoken.verify(token, request.keys.pem.publicKey) + return h.response(decoded).code(200) + } catch (e) { + request.logger.error(e) + return h.response('invalid token').code(401) + } + } +} + +export { userInfoController } diff --git a/src/api/oidc/helpers/oidc-crypto.js b/src/api/oidc/helpers/oidc-crypto.js index 6f69747..45c40d6 100644 --- a/src/api/oidc/helpers/oidc-crypto.js +++ b/src/api/oidc/helpers/oidc-crypto.js @@ -84,7 +84,6 @@ function generateCodeChallenge(method, codeVerifier) { } } -// If not manually set, computes the JWT headers' `kid` function keyID(pem) { const publicKey = crypto.createPublicKey({ key: pem, @@ -92,7 +91,6 @@ function keyID(pem) { type: 'spki' }) const publicKeyDER = publicKey.export({ type: 'spki', format: 'der' }) - return sha256(publicKeyDER) } diff --git a/src/api/oidc/index.js b/src/api/oidc/index.js index 67ece5b..68c5d52 100644 --- a/src/api/oidc/index.js +++ b/src/api/oidc/index.js @@ -4,7 +4,7 @@ import { jwksController } from '~/src/api/oidc/controllers/well-known-jwks' import { authorizeController } from '~/src/api/oidc/controllers/authorize-controller' import { tokenController } from '~/src/api/oidc/controllers/token-controller' import { loadOIDCKeys } from '~/src/api/oidc/helpers/oidc-crypto' -import { loginController } from '~/src/api/oidc/controllers/login-controller' +import { userInfoController } from '~/src/api/oidc/controllers/user-info-controller' const oidc = { plugin: { @@ -31,15 +31,15 @@ const oidc = { path: `${oidcBasePath}/authorize`, ...authorizeController }, - { - method: 'GET', - path: `${oidcBasePath}/login`, - ...loginController - }, { method: 'POST', path: `${oidcBasePath}/token`, ...tokenController + }, + { + method: 'GET', + path: `${oidcBasePath}/user-info`, + ...userInfoController } ]) } From 16207eccbd232d542b4878a158879edd969697a0 Mon Sep 17 00:00:00 2001 From: Chris Turner <23338096+christopherjturner@users.noreply.github.com> Date: Mon, 27 Nov 2023 07:44:05 +0000 Subject: [PATCH 4/6] moves oidc settings to config --- src/api/oidc/oidc-config.js | 16 ++++++---------- src/config/index.js | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/api/oidc/oidc-config.js b/src/api/oidc/oidc-config.js index 30e9c1f..c5755e6 100644 --- a/src/api/oidc/oidc-config.js +++ b/src/api/oidc/oidc-config.js @@ -1,9 +1,11 @@ +import { config } from '~/src/config' + const serverHost = 'http://localhost:3939' -const oidcBasePath = '/oidc' +const oidcBasePath = config.get('oidcBasePath') const oidcConfig = { - clientId: '63983fc2-cfff-45bb-8ec2-959e21062b9a', - clientSecret: 'test_value', + clientId: config.get('oidcClientId'), + clientSecret: config.get('oidcClientSecret'), issuerBase: `${serverHost}${oidcBasePath}`, authorizationEndpoint: `${serverHost}${oidcBasePath}/authorize`, tokenEndpoint: `${serverHost}${oidcBasePath}/token`, @@ -11,12 +13,6 @@ const oidcConfig = { jwksEndpoint: `${serverHost}${oidcBasePath}/.well-known/jwks.json`, discoveryEndpoint: `${serverHost}${oidcBasePath}/.well-known/openid-configuration`, - invalidRequest: 'invalid_request', - invalidClient: 'invalid_client', - invalidGrant: 'invalid_grant', - unsupportedGrantType: 'unsupported_grant_type', - invalidScope: 'invalid_scope', - internalServerError: 'internal_server_error', openidScope: 'openid', grantTypesSupported: [ @@ -56,7 +52,7 @@ const oidcConfig = { 'aud' ], codeChallengeMethodsSupported: ['plain', 'S256'], - ttl: 3600, // TODO: what unit are these? ms/sec? + ttl: 3600, // seconds refreshTtl: 3600 } diff --git a/src/config/index.js b/src/config/index.js index 1400072..fb49827 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -109,6 +109,24 @@ const config = convict({ format: String, default: 'http://127.0.0.1:4566/000000000000/ecs-deployments', env: 'SQS_ECS_QUEUE' + }, + oidcBasePath: { + doc: 'the base path all oidc stubs will be served from', + format: String, + default: '/oidc', + env: 'OIDC_BASE_PATH' + }, + oidcClientId: { + doc: 'client id to use in the oidc stub', + format: String, + default: '63983fc2-cfff-45bb-8ec2-959e21062b9a', + env: 'OIDC_CLIENT_ID' + }, + oidcClientSecret: { + doc: 'the client secret key for the oidc stub', + format: String, + default: 'test_value', + env: 'OIDC_CLIENT_SECRET' } }) From 454577fafdf3993da901fd386b528195c23baeef Mon Sep 17 00:00:00 2001 From: Chris Turner <23338096+christopherjturner@users.noreply.github.com> Date: Mon, 27 Nov 2023 09:49:57 +0000 Subject: [PATCH 5/6] config for server host --- src/api/oidc/oidc-config.js | 2 +- src/config/index.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/api/oidc/oidc-config.js b/src/api/oidc/oidc-config.js index c5755e6..2d82b07 100644 --- a/src/api/oidc/oidc-config.js +++ b/src/api/oidc/oidc-config.js @@ -1,6 +1,6 @@ import { config } from '~/src/config' -const serverHost = 'http://localhost:3939' +const serverHost = `${config.get('oidcServerHost')}:${config.get('port')}` const oidcBasePath = config.get('oidcBasePath') const oidcConfig = { diff --git a/src/config/index.js b/src/config/index.js index fb49827..cf0c30f 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -127,6 +127,12 @@ const config = convict({ format: String, default: 'test_value', env: 'OIDC_CLIENT_SECRET' + }, + oidcServerHost: { + doc: 'hostname of the stub server, used in wellknown urls endpoint', + format: String, + default: 'http://localhost', + env: 'OIDC_SERVER_HOST' } }) From 914a5c36f8630a7d9bb63583c54746cd2e509e52 Mon Sep 17 00:00:00 2001 From: Chris Turner <23338096+christopherjturner@users.noreply.github.com> Date: Wed, 29 Nov 2023 08:52:34 +0000 Subject: [PATCH 6/6] allows for pre-defined b64 encoded certs for oidc. also session admin endpoint for debugging --- .../controllers/oidc-session-controller.js | 9 ++++ src/api/admin/index.js | 6 +++ .../oidc/controllers/authorize-controller.js | 16 ++---- src/api/oidc/controllers/token-controller.js | 3 +- src/api/oidc/helpers/oidc-crypto.js | 51 +++++++++++++++---- src/api/oidc/helpers/render-login-page.js | 29 +++++++++++ src/api/oidc/index.js | 22 ++++++-- src/config/index.js | 12 +++++ 8 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 src/api/admin/controllers/oidc-session-controller.js create mode 100644 src/api/oidc/helpers/render-login-page.js diff --git a/src/api/admin/controllers/oidc-session-controller.js b/src/api/admin/controllers/oidc-session-controller.js new file mode 100644 index 0000000..4b7f164 --- /dev/null +++ b/src/api/admin/controllers/oidc-session-controller.js @@ -0,0 +1,9 @@ +import { sessions } from '~/src/api/oidc/helpers/session-store' + +const oidcSessionController = { + handler: async (request, h) => { + return h.response(sessions).code(200) + } +} + +export { oidcSessionController } diff --git a/src/api/admin/index.js b/src/api/admin/index.js index c1b5f6e..adac0c0 100644 --- a/src/api/admin/index.js +++ b/src/api/admin/index.js @@ -1,4 +1,5 @@ import { triggerEcrPush } from '~/src/api/admin/controllers/trigger-ecr-push' +import { oidcSessionController } from '~/src/api/admin/controllers/oidc-session-controller' const adminStub = { plugin: { @@ -9,6 +10,11 @@ const adminStub = { method: 'POST', path: '/_admin/trigger-ecr-push/{repo}/{tag}', ...triggerEcrPush + }, + { + method: 'GET', + path: '/_admin/oidc/sessions', + ...oidcSessionController } ]) } diff --git a/src/api/oidc/controllers/authorize-controller.js b/src/api/oidc/controllers/authorize-controller.js index 8fad9bf..d4a1336 100644 --- a/src/api/oidc/controllers/authorize-controller.js +++ b/src/api/oidc/controllers/authorize-controller.js @@ -1,25 +1,15 @@ -import { oidcBasePath, oidcConfig } from '~/src/api/oidc/oidc-config' +import { oidcConfig } from '~/src/api/oidc/oidc-config' import { validateScope } from '~/src/api/oidc/helpers/validate-scope' import { newSession } from '~/src/api/oidc/helpers/session-store' import { allUsers } from '~/src/api/oidc/helpers/users' - -const userToLink = (user, query) => { - return `${user}` -} +import { renderLoginPage } from '~/src/api/oidc/helpers/render-login-page' const authorizeController = { handler: (request, h) => { // a bit of a hack, but basically if the user param hasn't been set // show a 'login' page where they can select which fake user they want if (request.query.user === undefined) { - const fullUrl = new URL(request.url) - const page = ` -

Login

- ${Object.keys(allUsers) - .map((user) => userToLink(user, fullUrl.search)) - .join('
')} - ` - return h.response(page).header('content-type', 'text/html').code(200) + return renderLoginPage(request.url, h) } // TODO check these are all set, use joi or something diff --git a/src/api/oidc/controllers/token-controller.js b/src/api/oidc/controllers/token-controller.js index 00e3998..07cdf27 100644 --- a/src/api/oidc/controllers/token-controller.js +++ b/src/api/oidc/controllers/token-controller.js @@ -76,12 +76,11 @@ const tokenController = { tokenResponse.access_token = generateToken(request.keys, session) - // the example only checked the first item in the scope, unsure if this is correct or an oversight? if (session.scopes[0] === 'openid') { tokenResponse.id_token = generateIDToken(request.keys, session) } - if (grantType !== 'refresh_token') { + if (session.scopes[0] === 'refresh') { logger.info('generating a refresh token') tokenResponse.refresh_token = generateRefreshToken(request.keys, session) } diff --git a/src/api/oidc/helpers/oidc-crypto.js b/src/api/oidc/helpers/oidc-crypto.js index 45c40d6..8a5a6ed 100644 --- a/src/api/oidc/helpers/oidc-crypto.js +++ b/src/api/oidc/helpers/oidc-crypto.js @@ -4,13 +4,31 @@ import { oidcConfig } from '~/src/api/oidc/oidc-config' import jsonwebtoken from 'jsonwebtoken' import { jwk2pem } from 'pem-jwk' -function loadOIDCKeys() { - const jwk = generateRSAKeyPair() +function loadKeyPair(pub, priv) { + const privatePem = crypto.createPrivateKey({ + key: priv, + format: 'pem', + encoding: 'utf8' + }) + + const publicPem = crypto.createPublicKey({ + key: pub, + format: 'pem', + encoding: 'utf8' + }) + const pem = { - publicKey: jwk2pem(jwk.publicKey), - privateKey: jwk2pem(jwk.privateKey) + publicKey: publicPem, + privateKey: privatePem } - const keyId = keyID(pem.publicKey) + + const jwk = { + publicKey: pem.publicKey.export({ format: 'jwk' }), + privatePem: pem.privateKey.export({ format: 'jwk' }) + } + + const keyId = sha256(publicPem.export({ type: 'spki', format: 'der' })) + return { jwk, keyId, @@ -18,9 +36,8 @@ function loadOIDCKeys() { } } -function generateRSAKeyPair() { - // TODO: load these from file so they remain the same between restarts - return crypto.generateKeyPairSync('rsa', { +function generateRandomKeypair() { + const jwk = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, // 2048 bits is recommended for RSA keys publicKeyEncoding: { type: 'spki', @@ -31,6 +48,17 @@ function generateRSAKeyPair() { format: 'jwk' } }) + + const pem = { + publicKey: jwk2pem(jwk.publicKey), + privateKey: jwk2pem(jwk.privateKey) + } + const keyId = keyID(pem.publicKey) + return { + jwk, + keyId, + pem + } } function JWKS(keys) { @@ -84,9 +112,9 @@ function generateCodeChallenge(method, codeVerifier) { } } -function keyID(pem) { +function keyID(pemPublicKey) { const publicKey = crypto.createPublicKey({ - key: pem, + key: pemPublicKey, format: 'pem', type: 'spki' }) @@ -104,7 +132,8 @@ function sha256(input) { } export { - loadOIDCKeys, + loadKeyPair, + generateRandomKeypair, JWKS, generateToken, generateIDToken, diff --git a/src/api/oidc/helpers/render-login-page.js b/src/api/oidc/helpers/render-login-page.js new file mode 100644 index 0000000..9e1648c --- /dev/null +++ b/src/api/oidc/helpers/render-login-page.js @@ -0,0 +1,29 @@ +import { allUsers } from '~/src/api/oidc/helpers/users' +import { oidcBasePath } from '~/src/api/oidc/oidc-config' + +const userToLink = (user, query) => { + return `
  • ${allUsers[user].username} - ${allUsers[user].id}
  • ` +} + +const renderLoginPage = (url, h) => { + const queryParams = new URL(url).search + const page = ` +
    +

    CDP-Portal-Stubs - Login Stub

    + Select a user to login as: + + +

    +
    DEBUG:\n${decodeURIComponent(
    +          queryParams.replace('?', '').replaceAll('&', '\n')
    +        )}
    +
    + ` + return h.response(page).header('content-type', 'text/html').code(200) +} + +export { renderLoginPage } diff --git a/src/api/oidc/index.js b/src/api/oidc/index.js index 68c5d52..d85251c 100644 --- a/src/api/oidc/index.js +++ b/src/api/oidc/index.js @@ -3,15 +3,31 @@ import { openIdConfigurationController } from '~/src/api/oidc/controllers/well-k import { jwksController } from '~/src/api/oidc/controllers/well-known-jwks' import { authorizeController } from '~/src/api/oidc/controllers/authorize-controller' import { tokenController } from '~/src/api/oidc/controllers/token-controller' -import { loadOIDCKeys } from '~/src/api/oidc/helpers/oidc-crypto' +import { + generateRandomKeypair, + loadKeyPair +} from '~/src/api/oidc/helpers/oidc-crypto' import { userInfoController } from '~/src/api/oidc/controllers/user-info-controller' +import { config } from '~/src/config' const oidc = { plugin: { name: 'oidc', register: async (server) => { - // generate/load the keys and decorate the server/request with them - const keys = loadOIDCKeys() + const cfgPubKey = config.get('oidcPublicKeyBase64') + const cfgPrivKey = config.get('oidcPrivateKeyBase64') + + let keys + if (cfgPubKey && cfgPrivKey) { + server.logger.info('loading keys from config') + keys = loadKeyPair( + Buffer.from(cfgPubKey, 'base64'), + Buffer.from(cfgPrivKey, 'base64') + ) + } else { + server.logger.info('generating random keys') + keys = generateRandomKeypair() + } server.decorate('server', 'keys', keys) server.decorate('request', 'keys', keys) diff --git a/src/config/index.js b/src/config/index.js index cf0c30f..26e5f5c 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -133,6 +133,18 @@ const config = convict({ format: String, default: 'http://localhost', env: 'OIDC_SERVER_HOST' + }, + oidcPublicKeyBase64: { + doc: 'base 64 encoded public pem', + format: String, + default: undefined, + env: 'OIDC_PUBLIC_KEY_B64' + }, + oidcPrivateKeyBase64: { + doc: 'base 64 encoded private pem', + format: String, + default: undefined, + env: 'OIDC_PRIVATE_KEY_B64' } })