From ef83deb958d028d4eb76a22441a99caad9f3bbe9 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Tue, 24 Jan 2023 10:40:20 +0000 Subject: [PATCH 1/3] Run tests using a single context jest environment This fixes an issue when running tests that will provide jwks to openid-client as it does a comparison on the constructor of the object provided which does not match as the globals are different. For tests like the stateless session tests that need to use a jest mock like timers we can use the node environment to ensure timers work as expected --- package-lock.json | 130 ++++++------------ package.json | 3 +- .../session/stateless-session.test.ts | 4 + 3 files changed, 46 insertions(+), 91 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebb3c0160..53252439d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "jest": "^27.2.0", + "jest-environment-node-single-context": "^27.3.0", "next": "^13.1.3", "nock": "^13.0.5", "oidc-provider": "^7.6.0", @@ -855,15 +856,6 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/@edge-runtime/jest-environment/node_modules/@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/@edge-runtime/jest-environment/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1311,15 +1303,6 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/@jest/environment/node_modules/@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/@jest/environment/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1370,15 +1353,6 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/@jest/fake-timers/node_modules/@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/@jest/fake-timers/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2664,6 +2638,15 @@ "node": ">= 8" } }, + "node_modules/@types/yargs": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.20.tgz", + "integrity": "sha512-eknWrTHofQuPk2iuqDm1waA7V6xPlbgBoaaXEgYkClhLOnB0TtbW+srJaOToAgawPxPlHQzwypFA2bhZaUGP5A==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, "node_modules/@types/yargs-parser": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", @@ -8609,6 +8592,18 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/jest-environment-node-single-context": { + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node-single-context/-/jest-environment-node-single-context-27.3.0.tgz", + "integrity": "sha512-28sQFZhG92C6Zr+Tue9sHL9n68a40ZnQmyiu0Ajh3c7yRauX1x2PB9LyUxuZ9imcBOYKEzMitnG69WsSsP9bNg==", + "dev": true, + "dependencies": { + "jest-environment-node": "^27.2.4" + }, + "funding": { + "url": "https://github.com/kayahr/jest-environment-node-single-context?sponsor=1" + } + }, "node_modules/jest-environment-node/node_modules/@babel/code-frame": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", @@ -9060,15 +9055,6 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/jest-message-util/node_modules/@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/jest-message-util/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -9148,15 +9134,6 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, - "node_modules/jest-mock/node_modules/@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/jest-mock/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -14845,15 +14822,6 @@ "chalk": "^4.0.0" } }, - "@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -15214,15 +15182,6 @@ "chalk": "^4.0.0" } }, - "@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -15263,15 +15222,6 @@ "chalk": "^4.0.0" } }, - "@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -16299,6 +16249,15 @@ } } }, + "@types/yargs": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.20.tgz", + "integrity": "sha512-eknWrTHofQuPk2iuqDm1waA7V6xPlbgBoaaXEgYkClhLOnB0TtbW+srJaOToAgawPxPlHQzwypFA2bhZaUGP5A==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, "@types/yargs-parser": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", @@ -20897,6 +20856,15 @@ } } }, + "jest-environment-node-single-context": { + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node-single-context/-/jest-environment-node-single-context-27.3.0.tgz", + "integrity": "sha512-28sQFZhG92C6Zr+Tue9sHL9n68a40ZnQmyiu0Ajh3c7yRauX1x2PB9LyUxuZ9imcBOYKEzMitnG69WsSsP9bNg==", + "dev": true, + "requires": { + "jest-environment-node": "^27.2.4" + } + }, "jest-get-type": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", @@ -21162,15 +21130,6 @@ "chalk": "^4.0.0" } }, - "@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -21233,15 +21192,6 @@ "chalk": "^4.0.0" } }, - "@types/yargs": { - "version": "17.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", - "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index 0c64075c3..c4453711a 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "eslint-plugin-react-hooks": "^4.2.0", "jest": "^27.2.0", "next": "^13.1.3", + "jest-environment-node-single-context": "^27.3.0", "nock": "^13.0.5", "oidc-provider": "^7.6.0", "on-headers": "^1.0.2", @@ -136,7 +137,7 @@ "next": ">=10" }, "jest": { - "testEnvironment": "node", + "testEnvironment": "jest-environment-node-single-context", "rootDir": ".", "moduleFileExtensions": [ "ts", diff --git a/tests/auth0-session/session/stateless-session.test.ts b/tests/auth0-session/session/stateless-session.test.ts index 16b21fd6a..3876ad598 100644 --- a/tests/auth0-session/session/stateless-session.test.ts +++ b/tests/auth0-session/session/stateless-session.test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import { randomBytes } from 'crypto'; import * as jose from 'jose'; import { IdTokenClaims } from 'openid-client'; From f3359271c6ef2662779288b362f4522f380f3eee Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Tue, 24 Jan 2023 10:42:12 +0000 Subject: [PATCH 2/3] Minor updates to examples gitignore and .env template --- examples/basic-example/.gitignore | 6 ++++++ examples/kitchen-sink-example/.env.local.template | 2 ++ 2 files changed, 8 insertions(+) create mode 100644 examples/basic-example/.gitignore diff --git a/examples/basic-example/.gitignore b/examples/basic-example/.gitignore new file mode 100644 index 000000000..5d054be8d --- /dev/null +++ b/examples/basic-example/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.next +.DS_Store +package-lock.json diff --git a/examples/kitchen-sink-example/.env.local.template b/examples/kitchen-sink-example/.env.local.template index 7ac7eb8fd..fdba0fa17 100644 --- a/examples/kitchen-sink-example/.env.local.template +++ b/examples/kitchen-sink-example/.env.local.template @@ -3,3 +3,5 @@ AUTH0_ISSUER_BASE_URL= AUTH0_BASE_URL= AUTH0_CLIENT_ID= AUTH0_CLIENT_SECRET= +AUTH0_AUDIENCE= +AUTH0_SCOPE=openid profile email read:shows From 4d3790e1f07c4d11873ef70bdbc869a544f7efb4 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Tue, 24 Jan 2023 10:48:03 +0000 Subject: [PATCH 3/3] Add support for JWT client authentication --- src/auth0-session/client.ts | 26 +++- src/auth0-session/config.ts | 24 +++- src/auth0-session/get-config.ts | 42 ++++++- src/config.ts | 24 +++- tests/auth0-session/config.test.ts | 42 ++++++- tests/auth0-session/fixtures/helpers.ts | 12 ++ tests/auth0-session/fixtures/private-key.pem | 28 +++++ tests/auth0-session/fixtures/well-known.json | 1 + tests/auth0-session/handlers/callback.test.ts | 114 +++++++++++++++++- tests/config.test.ts | 2 + 10 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 tests/auth0-session/fixtures/private-key.pem diff --git a/src/auth0-session/client.ts b/src/auth0-session/client.ts index 1aff8bb63..481060490 100644 --- a/src/auth0-session/client.ts +++ b/src/auth0-session/client.ts @@ -1,10 +1,12 @@ -import { Issuer, custom, Client, EndSessionParameters } from 'openid-client'; +import { Issuer, custom, Client, EndSessionParameters, ClientAuthMethod } from 'openid-client'; import url, { UrlObject } from 'url'; import urlJoin from 'url-join'; import createDebug from './utils/debug'; import { DiscoveryError } from './utils/errors'; import { Config } from './config'; import { ParsedUrlQueryInput } from 'querystring'; +import { exportJWK } from 'jose'; +import { createPrivateKey } from 'crypto'; const debug = createDebug('client'); @@ -88,11 +90,23 @@ export default function get(config: Config, { name, version }: Telemetry): Clien ); } - client = new issuer.Client({ - client_id: config.clientID, - client_secret: config.clientSecret, - id_token_signed_response_alg: config.idTokenSigningAlg - }); + let jwks; + if (config.clientAssertionSigningKey) { + const privateKey = createPrivateKey({ key: config.clientAssertionSigningKey }); + const jwk = await exportJWK(privateKey); + jwks = { keys: [jwk] }; + } + + client = new issuer.Client( + { + client_id: config.clientID, + client_secret: config.clientSecret, + id_token_signed_response_alg: config.idTokenSigningAlg, + token_endpoint_auth_method: config.clientAuthMethod as ClientAuthMethod, + token_endpoint_auth_signing_alg: config.clientAssertionSigningAlg + }, + jwks + ); client[custom.clock_tolerance] = config.clockTolerance; if (config.idpLogout && !issuer.end_session_endpoint) { diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts index f9bff69e7..df45f82d2 100644 --- a/src/auth0-session/config.ts +++ b/src/auth0-session/config.ts @@ -1,5 +1,5 @@ import type { IncomingMessage } from 'http'; -import type { AuthorizationParameters as OidcAuthorizationParameters } from 'openid-client'; +import type { AuthorizationParameters as OidcAuthorizationParameters, ClientAuthMethod } from 'openid-client'; import { SessionStore } from './session/stateful-session'; /** @@ -162,6 +162,28 @@ export interface Config { */ callback: string; }; + + /** + * The clients authentication method. Default is `none` when using response_type='id_token`,`private_key_jwt` when + * using a `clientAssertionSigningKey`, otherwise `client_secret_basic`. + */ + clientAuthMethod?: ClientAuthMethod; + + /** + * Private key for use with `private_key_jwt` clients. + * This should be a string that is the contents of a PEM file. + * you can also use the `AUTH0_CLIENT_ASSERTION_SIGNING_KEY` environment variable. + */ + clientAssertionSigningKey?: string; + + /** + * The algorithm used to sign the client assertion JWT. + * Uses one of `token_endpoint_auth_signing_alg_values_supported` if not specified. + * If the Authorization Server discovery document does not list `token_endpoint_auth_signing_alg_values_supported` + * this property will be required. + * You can also use the `AUTH0_CLIENT_ASSERTION_SIGNING_ALG` environment variable. + */ + clientAssertionSigningAlg?: string; } /** diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts index d2ead312e..488f49acd 100644 --- a/src/auth0-session/get-config.ts +++ b/src/auth0-session/get-config.ts @@ -88,13 +88,13 @@ const paramsSchema = Joi.object({ clientID: Joi.string().required(), clientSecret: Joi.string() .when( - Joi.ref('authorizationParams.response_type', { - adjust: (value) => value && value.includes('code') + Joi.ref('clientAuthMethod', { + adjust: (value) => value && value.includes('client_secret') }), { is: true, then: Joi.string().required().messages({ - 'any.required': '"clientSecret" is required for a response_type that includes code' + 'any.required': '"clientSecret" is required for the clientAuthMethod {{clientAuthMethod}}' }) } ) @@ -131,11 +131,41 @@ const paramsSchema = Joi.object({ .default() .unknown(false), clientAuthMethod: Joi.string() - .valid('client_secret_basic', 'client_secret_post', 'none') + .valid('client_secret_basic', 'client_secret_post', 'client_secret_jwt', 'private_key_jwt', 'none') .optional() .default((parent) => { - return parent.authorizationParams.response_type === 'id_token' ? 'none' : 'client_secret_basic'; + if (parent.authorizationParams.response_type === 'id_token') { + return 'none'; + } + + if (parent.clientAssertionSigningKey) { + return 'private_key_jwt'; + } + + return 'client_secret_basic'; }) + .when( + Joi.ref('authorizationParams.response_type', { + adjust: (value) => value && value.includes('code') + }), + { + is: true, + then: Joi.string().invalid('none').messages({ + 'any.only': 'Public code flow clients are not supported.' + }) + } + ), + clientAssertionSigningKey: Joi.any() + .optional() + .when(Joi.ref('clientAuthMethod'), { + is: 'private_key_jwt', + then: Joi.any().required().messages({ + 'any.required': '"clientAssertionSigningKey" is required for a "clientAuthMethod" of "private_key_jwt"' + }) + }), + clientAssertionSigningAlg: Joi.string() + .optional() + .valid('RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES256K', 'ES384', 'ES512', 'EdDSA') }); export type DeepPartial = { @@ -145,7 +175,7 @@ export type DeepPartial = { export type ConfigParameters = DeepPartial; export const get = (params: ConfigParameters = {}): Config => { - const { value, error, warning } = paramsSchema.validate(params); + const { value, error, warning } = paramsSchema.validate(params, { allowUnknown: true }); if (error) { throw new TypeError(error.details[0].message); } diff --git a/src/config.ts b/src/config.ts index 3ad4c8e58..31f11e3f9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -172,6 +172,22 @@ export interface BaseConfig { */ callback: string; }; + + /** + * Private key for use with `private_key_jwt` clients. + * This should be a string that is the contents of a PEM file. + * You can also use the `AUTH0_CLIENT_ASSERTION_SIGNING_KEY` environment variable. + */ + clientAssertionSigningKey?: string; + + /** + * The algorithm to sign the client assertion JWT. + * Uses one of `token_endpoint_auth_signing_alg_values_supported` if not specified. + * If the Authorization Server discovery document does not list `token_endpoint_auth_signing_alg_values_supported` + * this property will be required. + * You can also use the `AUTH0_CLIENT_ASSERTION_SIGNING_ALG` environment variable. + */ + clientAssertionSigningAlg?: string; } /** @@ -382,6 +398,8 @@ export interface NextConfig extends Pick { * - `AUTH0_COOKIE_HTTP_ONLY`: See {@link CookieConfig.httpOnly}. * - `AUTH0_COOKIE_SECURE`: See {@link CookieConfig.secure}. * - `AUTH0_COOKIE_SAME_SITE`: See {@link CookieConfig.sameSite}. + * - `AUTH0_CLIENT_ASSERTION_SIGNING_KEY`: See {@link BaseConfig.clientAssertionSigningKey} + * - `AUTH0_CLIENT_ASSERTION_SIGNING_ALG`: See {@link BaseConfig.clientAssertionSigningAlg} * * ### 2. Create your own instance using {@link InitAuth0} * @@ -480,6 +498,8 @@ export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConf const AUTH0_COOKIE_HTTP_ONLY = process.env.AUTH0_COOKIE_HTTP_ONLY; const AUTH0_COOKIE_SECURE = process.env.AUTH0_COOKIE_SECURE; const AUTH0_COOKIE_SAME_SITE = process.env.AUTH0_COOKIE_SAME_SITE; + const AUTH0_CLIENT_ASSERTION_SIGNING_KEY = process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY; + const AUTH0_CLIENT_ASSERTION_SIGNING_ALG = process.env.AUTH0_CLIENT_ASSERTION_SIGNING_ALG; const baseURL = AUTH0_BASE_URL && !/^https?:\/\//.test(AUTH0_BASE_URL as string) ? `https://${AUTH0_BASE_URL}` : AUTH0_BASE_URL; @@ -533,7 +553,9 @@ export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConf routes: { callback: baseParams.routes?.callback || AUTH0_CALLBACK || '/api/auth/callback', postLogoutRedirect: baseParams.routes?.postLogoutRedirect || AUTH0_POST_LOGOUT_REDIRECT - } + }, + clientAssertionSigningKey: AUTH0_CLIENT_ASSERTION_SIGNING_KEY, + clientAssertionSigningAlg: AUTH0_CLIENT_ASSERTION_SIGNING_ALG }); const nextConfig = { diff --git a/tests/auth0-session/config.test.ts b/tests/auth0-session/config.test.ts index 35786426c..ff9a26f2a 100644 --- a/tests/auth0-session/config.test.ts +++ b/tests/auth0-session/config.test.ts @@ -345,7 +345,7 @@ describe('Config', () => { response_type: 'code' } }) - ).toThrowError(new TypeError('"clientSecret" is required for a response_type that includes code')); + ).toThrowError(new TypeError('"clientSecret" is required for the clientAuthMethod client_secret_basic')); }); it("shouldn't allow hybrid flow without clientSecret", () => { @@ -356,7 +356,45 @@ describe('Config', () => { response_type: 'code id_token' } }) - ).toThrowError(new TypeError('"clientSecret" is required for a response_type that includes code')); + ).toThrowError(new TypeError('"clientSecret" is required for the clientAuthMethod client_secret_basic')); + }); + + it(`shouldn't allow code flow without client authn when clientAuthMethod is "none"`, () => { + expect(() => + getConfig({ + ...defaultConfig, + authorizationParams: { + response_type: 'code' + }, + clientAuthMethod: 'none' + }) + ).toThrowError(new TypeError('Public code flow clients are not supported.')); + }); + + it('should require "clientAssertionSigningKey" when clientAuthMethod is "private_key_jwt"', () => { + expect(() => + getConfig({ + ...defaultConfig, + authorizationParams: { + response_type: 'code' + }, + clientAuthMethod: 'private_key_jwt' + }) + ).toThrowError( + new TypeError('"clientAssertionSigningKey" is required for a "clientAuthMethod" of "private_key_jwt"') + ); + }); + + it('should default to "private_key_jwt" when "clientAssertionSigningKey" is present', () => { + expect( + getConfig({ + ...defaultConfig, + authorizationParams: { + response_type: 'code' + }, + clientAssertionSigningKey: 'foo' + }).clientAuthMethod + ).toBe('private_key_jwt'); }); it('should not allow "none" for idTokenSigningAlg', () => { diff --git a/tests/auth0-session/fixtures/helpers.ts b/tests/auth0-session/fixtures/helpers.ts index fafd9352e..34b0bd21c 100644 --- a/tests/auth0-session/fixtures/helpers.ts +++ b/tests/auth0-session/fixtures/helpers.ts @@ -4,6 +4,7 @@ import { generateCookieValue } from '../../../src/auth0-session/utils/signed-coo import { IncomingMessage, request as nodeHttpRequest } from 'http'; import { request as nodeHttpsRequest } from 'https'; import { ConfigParameters } from '../../../src/auth0-session'; +import { base64url } from 'jose'; const secret = '__test_session_secret__'; const clientId = '__test_client_id__'; @@ -113,3 +114,14 @@ export const post = async ( fullResponse }: { body: { [key: string]: any }; cookieJar?: CookieJar; fullResponse?: boolean; https?: boolean } ): Promise => request(`${baseURL}${path}`, 'POST', { body, cookieJar, fullResponse }); + +export const decodeJWT = ( + token: string +): { header: Record; payload: Record; signature: string } => { + const { 0: header, 1: payload, 2: signature } = token.split('.'); + return { + header: JSON.parse(base64url.decode(header).toString()), + payload: JSON.parse(base64url.decode(payload).toString()), + signature + }; +}; diff --git a/tests/auth0-session/fixtures/private-key.pem b/tests/auth0-session/fixtures/private-key.pem new file mode 100644 index 000000000..d1391f9cf --- /dev/null +++ b/tests/auth0-session/fixtures/private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDbTKOQLtaZ6U1k +3fcYCMVoy8poieNPPcbj15TCLOm4Bbox73/UUxIArqczVcjtUGnL+jn5982V5EiB +y8W51m5K9mIBgEFLYdLkXk+OW5UTE/AdMPtfsIjConGrrs3mxN4WSH9kvh9Yr41r +hWUUSwqFyMOssbGE8K46Cv0WYvS7RXH9MzcyTcMSFp/60yUXH4rdHYZElF7XCdiE +63WxebxI1Qza4xkjTlbp5EWfWBQB1Ms10JO8NjrtkCXrDI57Bij5YanPAVhctcO9 +z5/y9i5xEzcer8ZLO8VDiXSdEsuP/fe+UKDyYHUITD8u51p3O2JwCKvdTHduemej +3Kd1RlHrAgMBAAECggEATWdzpASkQpcSdjPSb21JIIAt5VAmJ2YKuYjyPMdVh1qe +Kdn7KJpZlFwRMBFrZjgn35Nmu1A4BFwbK5UdKUcCjvsABL+cTFsu8ORI+Fpi9+Tl +r6gGUfQhkXF85bhBfN6n9P2J2akxrz/njrf6wXrrL+V5C498tQuus1YFls0+zIpD +N+GngNOPHlGeY3gW4K/HjGuHwuJOvWNmE4KNQhBijdd50Am824Y4NV/SmsIo7z+s +8CLjp/qtihwnE4rkUHnR6M4u5lpzXOnodzkDTG8euOJds0T8DwLNTx1b+ETim35i +D/hOCVwl8QFoj2aatjuJ5LXZtZUEpGpBF2TQecB+gQKBgQDvaZ1jG/FNPnKdayYv +z5yTOhKM6JTB+WjB0GSx8rebtbFppiHGgVhOd1bLIzli9uMOPdCNuXh7CKzIgSA6 +Q76Wxfuaw8F6CBIdlG9bZNL6x8wp6zF8tGz/BgW7fFKBwFYSWzTcStGr2QGtwr6F +9p1gYPSGfdERGOQc7RmhoNNHcQKBgQDqfkhpPfJlP/SdFnF7DDUvuMnaswzUsM6D +ZPhvfzdMBV8jGc0WjCW2Vd3pvsdPgWXZqAKjN7+A5HiT/8qv5ruoqOJSR9ZFZI/B +8v+8gS9Af7K56mCuCFKZmOXUmaL+3J2FKtzAyOlSLjEYyLuCgmhEA9Zo+duGR5xX +AIjx7N/ZGwKBgCZAYqQeJ8ymqJtcLkq/Sg3/3kzjMDlZxxIIYL5JwGpBemod4BGe +QuSujpCAPUABoD97QuIR+xz1Qt36O5LzlfTzBwMwOa5ssbBGMhCRKGBnIcikylBZ +Z3zLkojlES2n9FiUd/qmfZ+OWYVQsy4mO/jVJNyEJ64qou+4NjsrvfYRAoGAORki +3K1+1nSqRY3vd/zS/pnKXPx4RVoADzKI4+1gM5yjO9LOg40AqdNiw8X2lj9143fr +nH64nNQFIFSKsCZIz5q/8TUY0bDY6GsZJnd2YAg4JtkRTY8tPcVjQU9fxxtFJ+X1 +9uN1HNOulNBcCD1k0hr1HH6qm5nYUb8JmY8KOr0CgYB85pvPhBqqfcWi6qaVQtK1 +ukIdiJtMNPwePfsT/2KqrbnftQnAKNnhsgcYGo8NAvntX4FokOAEdunyYmm85mLp +BGKYgVXJqnm6+TJyCRac1ro3noG898P/LZ8MOBoaYQtWeWRpDc46jPrA0FqUJy+i +ca/T0LLtgmbMmxSv/MmzIg== +-----END PRIVATE KEY----- diff --git a/tests/auth0-session/fixtures/well-known.json b/tests/auth0-session/fixtures/well-known.json index 41c3e5376..e288a04f8 100644 --- a/tests/auth0-session/fixtures/well-known.json +++ b/tests/auth0-session/fixtures/well-known.json @@ -37,6 +37,7 @@ "response_modes_supported": ["query", "fragment", "form_post"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["HS256", "RS256"], + "token_endpoint_auth_signing_alg_values_supported": ["HS256", "RS256"], "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post" diff --git a/tests/auth0-session/handlers/callback.test.ts b/tests/auth0-session/handlers/callback.test.ts index 3e4631636..e17779e1e 100644 --- a/tests/auth0-session/handlers/callback.test.ts +++ b/tests/auth0-session/handlers/callback.test.ts @@ -5,8 +5,13 @@ import { signing } from '../../../src/auth0-session/utils/hkdf'; import { encodeState } from '../../../src/auth0-session/utils/encoding'; import { SessionResponse, setup, teardown } from '../fixtures/server'; import { makeIdToken } from '../fixtures/cert'; -import { toSignedCookieJar, get, post, defaultConfig } from '../fixtures/helpers'; +import { toSignedCookieJar, get, post, defaultConfig, decodeJWT } from '../fixtures/helpers'; import { ServerResponse } from 'http'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as qs from 'querystring'; + +const privateKey = readFileSync(join(__dirname, '..', 'fixtures', 'private-key.pem'), 'utf-8'); const expectedDefaultState = encodeState({ returnTo: 'https://example.org' }); @@ -378,6 +383,113 @@ describe('callback', () => { ); }); + it('should use private key jwt on token endpoint', async () => { + const idToken = await makeIdToken({ + c_hash: '77QmUPtjPfzWtF2AnpK9RQ' + }); + + const baseURL = await setup({ + ...defaultConfig, + authorizationParams: { + response_type: 'code' + }, + clientAssertionSigningKey: privateKey + }); + + let body: qs.ParsedUrlQuery = {}; + nock('https://op.example.com') + .post('/oauth/token') + .reply(200, function (_uri, requestBody) { + body = qs.parse(requestBody as string); + return { + access_token: '__test_access_token__', + refresh_token: '__test_refresh_token__', + id_token: idToken, + token_type: 'Bearer', + expires_in: 86400 + }; + }); + + const cookieJar = await toSignedCookieJar( + { + state: expectedDefaultState, + nonce: '__test_nonce__' + }, + baseURL + ); + + await post(baseURL, '/callback', { + body: { + state: expectedDefaultState, + id_token: idToken, + code: 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y' + }, + cookieJar, + fullResponse: true + }); + + expect(body.client_assertion).not.toBeUndefined(); + expect(body.client_assertion_type).toEqual('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + const { header } = decodeJWT(body.client_assertion as string); + + expect(header.alg).toEqual('RS256'); + }); + + it('should use client secret jwt on token endpoint', async () => { + const idToken = await makeIdToken({ + c_hash: '77QmUPtjPfzWtF2AnpK9RQ' + }); + + const baseURL = await setup({ + ...defaultConfig, + authorizationParams: { + response_type: 'code' + }, + clientSecret: 'foo', + clientAuthMethod: 'client_secret_jwt' + }); + + let body: qs.ParsedUrlQuery = {}; + nock('https://op.example.com') + .post('/oauth/token') + .reply(200, function (_uri, requestBody) { + body = qs.parse(requestBody as string); + return { + access_token: '__test_access_token__', + refresh_token: '__test_refresh_token__', + id_token: idToken, + token_type: 'Bearer', + expires_in: 86400 + }; + }); + + const cookieJar = await toSignedCookieJar( + { + state: expectedDefaultState, + nonce: '__test_nonce__' + }, + baseURL + ); + + await post(baseURL, '/callback', { + body: { + state: expectedDefaultState, + id_token: idToken, + code: 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y' + }, + cookieJar, + fullResponse: true + }); + + expect(body.client_assertion).not.toBeUndefined(); + expect(body.client_assertion_type).toEqual('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + const { header } = decodeJWT(body.client_assertion as string); + + expect(header.alg).toEqual('HS256'); + }); + it('should redirect to default base url', async () => { const baseURL = await setup(defaultConfig); diff --git a/tests/config.test.ts b/tests/config.test.ts index e29ec1be4..32d5fd32d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -29,6 +29,8 @@ describe('config params', () => { secret: '__long_super_secret_secret__', issuerBaseURL: 'https://example.auth0.com', baseURL: 'https://example.com', + clientAssertionSigningAlg: undefined, + clientAssertionSigningKey: undefined, clientID: '__test_client_id__', clientSecret: '__test_client_secret__', clockTolerance: 60,