From d06d3529febecd2d8fa128540dc74e21c090b7e4 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Wed, 6 Dec 2023 13:56:38 +0000 Subject: [PATCH] Add Pushed Authorization Requests (#1598) Co-authored-by: Adam Mcgrath --- .eslintrc | 1 - src/auth0-session/client/edge-client.ts | 23 +++++++++++- src/auth0-session/client/node-client.ts | 12 +++++++ src/auth0-session/config.ts | 8 +++++ src/auth0-session/get-config.ts | 13 +++++-- src/config.ts | 3 ++ .../auth0-session/client/edge-client.test.ts | 23 +++++++++++- .../auth0-session/client/node-client.test.ts | 21 +++++++++++ tests/auth0-session/config.test.ts | 10 ++++++ tests/auth0-session/fixtures/server.ts | 2 ++ tests/auth0-session/fixtures/well-known.json | 1 + tests/auth0-session/handlers/login.test.ts | 26 ++++++++++++++ tests/config.test.ts | 3 +- tests/fixtures/app-router-helpers.ts | 8 +++-- tests/fixtures/oidc-nocks.ts | 5 +++ tests/fixtures/setup.ts | 14 ++++++-- tests/handlers/login-page-router.test.ts | 27 ++++++++++++++ tests/handlers/login.test.ts | 36 +++++++++++++++++++ 18 files changed, 224 insertions(+), 12 deletions(-) diff --git a/.eslintrc b/.eslintrc index efb92c296..151b7de1e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -37,7 +37,6 @@ "no-console": [1, { "allow": ["error", "info", "warn"] }], - "max-len": ["error", 120], "comma-dangle": ["error", "never"], "no-trailing-spaces": "error", "react/display-name": 0, diff --git a/src/auth0-session/client/edge-client.ts b/src/auth0-session/client/edge-client.ts index 354eda529..371f6ecf2 100644 --- a/src/auth0-session/client/edge-client.ts +++ b/src/auth0-session/client/edge-client.ts @@ -75,6 +75,12 @@ export class EdgeClient extends AbstractClient { throw new DiscoveryError(e, this.config.issuerBaseURL); } + if (this.config.pushedAuthorizationRequests && !this.as.pushed_authorization_request_endpoint) { + throw new TypeError( + 'pushed_authorization_request_endpoint must be configured on the issuer to use pushedAuthorizationRequests' + ); + } + this.client = { client_id: this.config.clientID, ...(!this.config.clientAssertionSigningKey && { client_secret: this.config.clientSecret }), @@ -87,7 +93,22 @@ export class EdgeClient extends AbstractClient { } async authorizationUrl(parameters: Record): Promise { - const [as] = await this.getClient(); + const [as, client] = await this.getClient(); + + if (this.config.pushedAuthorizationRequests) { + const response = await oauth.pushedAuthorizationRequest(as, client, parameters as Record); + const result = await oauth.processPushedAuthorizationResponse(as, client, response); + if (oauth.isOAuth2Error(result)) { + throw new IdentityProviderError({ + message: result.error_description || result.error, + error: result.error, + error_description: result.error_description + }); + } + + parameters = { request_uri: result.request_uri }; + } + const authorizationUrl = new URL(as.authorization_endpoint as string); authorizationUrl.searchParams.set('client_id', this.config.clientID); Object.entries(parameters).forEach(([key, value]) => { diff --git a/src/auth0-session/client/node-client.ts b/src/auth0-session/client/node-client.ts index 0ea9cdbf2..d7f1f8d76 100644 --- a/src/auth0-session/client/node-client.ts +++ b/src/auth0-session/client/node-client.ts @@ -112,6 +112,12 @@ export class NodeClient extends AbstractClient { ); } + if (config.pushedAuthorizationRequests && !issuer.pushed_authorization_request_endpoint) { + throw new TypeError( + 'pushed_authorization_request_endpoint must be configured on the issuer to use pushedAuthorizationRequests' + ); + } + let jwks; if (config.clientAssertionSigningKey) { const privateKey = createPrivateKey({ key: config.clientAssertionSigningKey as string }); @@ -164,6 +170,12 @@ export class NodeClient extends AbstractClient { async authorizationUrl(parameters: Record): Promise { const client = await this.getClient(); + + if (this.config.pushedAuthorizationRequests) { + const { request_uri } = await client.pushedAuthorizationRequest(parameters); + parameters = { request_uri }; + } + return client.authorizationUrl(parameters); } diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts index 27c2ce8e2..708be8824 100644 --- a/src/auth0-session/config.ts +++ b/src/auth0-session/config.ts @@ -218,6 +218,14 @@ export interface Config { * See: https://openid.net/specs/openid-connect-backchannel-1_0.html */ backchannelLogout: boolean | BackchannelLogoutOptions; + + /** + * Set to `true` to perform a Pushed Authorization Request at the issuer's + * `pushed_authorization_request_endpoint` at login. + * + * See: https://www.rfc-editor.org/rfc/rfc9126.html + */ + pushedAuthorizationRequests: boolean; } export interface BackchannelLogoutOptions { diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts index 31446fdbc..ae309dd01 100644 --- a/src/auth0-session/get-config.ts +++ b/src/auth0-session/get-config.ts @@ -147,7 +147,7 @@ const paramsSchema = Joi.object({ .valid('client_secret_basic', 'client_secret_post', 'client_secret_jwt', 'private_key_jwt', 'none') .optional() .default((parent) => { - if (parent.authorizationParams.response_type === 'id_token') { + if (parent.authorizationParams.response_type === 'id_token' && !parent.pushedAuthorizationRequests) { return 'none'; } @@ -167,7 +167,13 @@ const paramsSchema = Joi.object({ 'any.only': 'Public code flow clients are not supported.' }) } - ), + ) + .when(Joi.ref('pushedAuthorizationRequests'), { + is: true, + then: Joi.string().invalid('none').messages({ + 'any.only': 'Public PAR clients are not supported' + }) + }), clientAssertionSigningKey: Joi.any() .optional() .when(Joi.ref('clientAuthMethod'), { @@ -193,7 +199,8 @@ const paramsSchema = Joi.object({ store: Joi.object().optional() }), Joi.boolean() - ]).default(false) + ]).default(false), + pushedAuthorizationRequests: Joi.boolean().optional().default(false) }); export type DeepPartial = { diff --git a/src/config.ts b/src/config.ts index a68873d91..cd5a2d75d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,6 +58,7 @@ export interface NextConfig extends BaseConfig { * - `AUTH0_ID_TOKEN_SIGNING_ALG`: See {@link BaseConfig.idTokenSigningAlg}. * - `AUTH0_LEGACY_SAME_SITE_COOKIE`: See {@link BaseConfig.legacySameSiteCookie}. * - `AUTH0_IDENTITY_CLAIM_FILTER`: See {@link BaseConfig.identityClaimFilter}. + * - `AUTH0_PUSHED_AUTHORIZATION_REQUESTS` See {@link BaseConfig.pushedAuthorizationRequests}. * - `NEXT_PUBLIC_AUTH0_LOGIN`: See {@link NextConfig.routes}. * - `AUTH0_CALLBACK`: See {@link BaseConfig.routes}. * - `AUTH0_POST_LOGOUT_REDIRECT`: See {@link BaseConfig.routes}. @@ -158,6 +159,7 @@ export const getConfig = (params: ConfigParameters = {}): NextConfig => { const AUTH0_ID_TOKEN_SIGNING_ALG = process.env.AUTH0_ID_TOKEN_SIGNING_ALG; const AUTH0_LEGACY_SAME_SITE_COOKIE = process.env.AUTH0_LEGACY_SAME_SITE_COOKIE; const AUTH0_IDENTITY_CLAIM_FILTER = process.env.AUTH0_IDENTITY_CLAIM_FILTER; + const AUTH0_PUSHED_AUTHORIZATION_REQUESTS = process.env.AUTH0_PUSHED_AUTHORIZATION_REQUESTS; const AUTH0_CALLBACK = process.env.AUTH0_CALLBACK; const AUTH0_POST_LOGOUT_REDIRECT = process.env.AUTH0_POST_LOGOUT_REDIRECT; const AUTH0_AUDIENCE = process.env.AUTH0_AUDIENCE; @@ -202,6 +204,7 @@ export const getConfig = (params: ConfigParameters = {}): NextConfig => { idTokenSigningAlg: AUTH0_ID_TOKEN_SIGNING_ALG, legacySameSiteCookie: bool(AUTH0_LEGACY_SAME_SITE_COOKIE), identityClaimFilter: array(AUTH0_IDENTITY_CLAIM_FILTER), + pushedAuthorizationRequests: bool(AUTH0_PUSHED_AUTHORIZATION_REQUESTS, false), ...baseParams, authorizationParams: { response_type: 'code', diff --git a/tests/auth0-session/client/edge-client.test.ts b/tests/auth0-session/client/edge-client.test.ts index 48b7805c9..81718e5a3 100644 --- a/tests/auth0-session/client/edge-client.test.ts +++ b/tests/auth0-session/client/edge-client.test.ts @@ -57,7 +57,7 @@ const getClient = async (params: ConfigParameters = {}): Promise => }; describe('edge client', function () { - let headersSpy = jest.fn(); + const headersSpy = jest.fn(); beforeEach(() => { mockFetch(); @@ -388,4 +388,25 @@ describe('edge client', function () { 'The request to refresh the access token failed. CAUSE: bar' ); }); + + it('should throw an error if "pushedAuthorizationRequests" is enabled and issuer does not support pushed_authorization_request_endpoint', async function () { + nock.cleanAll(); + nock('https://op.example.com') + .get('/.well-known/openid-configuration') + .reply(200, { + ...wellKnown, + pushed_authorization_request_endpoint: undefined + }); + const client = await getClient({ ...defaultConfig, pushedAuthorizationRequests: true }); + // @ts-ignore + await expect(client.getClient()).rejects.toThrow( + 'pushed_authorization_request_endpoint must be configured on the issuer to use pushedAuthorizationRequests' + ); + }); + + it('should succeed if "pushedAuthorizationRequests" is enabled and issuer supports pushed_authorization_request_endpoint', async function () { + const client = await getClient({ ...defaultConfig, pushedAuthorizationRequests: true }); + // @ts-ignore + await expect(client.getClient()).resolves.not.toThrow(); + }); }); diff --git a/tests/auth0-session/client/node-client.test.ts b/tests/auth0-session/client/node-client.test.ts index 1aa983d51..bb55d548e 100644 --- a/tests/auth0-session/client/node-client.test.ts +++ b/tests/auth0-session/client/node-client.test.ts @@ -220,4 +220,25 @@ describe('node client', function () { nock('https://op.example.com').get('/userinfo').reply(500, {}); await expect((await getClient()).userinfo('token')).rejects.toThrow(UserInfoError); }); + + it('should throw an error if "pushedAuthorizationRequests" is enabled and issuer does not support pushed_authorization_request_endpoint', async function () { + nock.cleanAll(); + nock('https://op.example.com') + .get('/.well-known/openid-configuration') + .reply(200, { + ...wellKnown, + pushed_authorization_request_endpoint: undefined + }); + const client = await getClient({ ...defaultConfig, pushedAuthorizationRequests: true }); + // @ts-ignore + await expect(client.getClient()).rejects.toThrow( + 'pushed_authorization_request_endpoint must be configured on the issuer to use pushedAuthorizationRequests' + ); + }); + + it('should succeed if "pushedAuthorizationRequests" is enabled and issuer supports pushed_authorization_request_endpoint', async function () { + const client = await getClient({ ...defaultConfig, pushedAuthorizationRequests: true }); + // @ts-ignore + await expect(client.getClient()).resolves.not.toThrow() + }); }); diff --git a/tests/auth0-session/config.test.ts b/tests/auth0-session/config.test.ts index bf78fa829..adc7c8594 100644 --- a/tests/auth0-session/config.test.ts +++ b/tests/auth0-session/config.test.ts @@ -576,4 +576,14 @@ describe('Config', () => { }) ).not.toThrow(); }); + + it(`shouldn't allow pushed authentication requests when clientAuthMethod is "none"`, () => { + expect(() => + getConfig({ + ...defaultConfig, + clientAuthMethod: 'none', + pushedAuthorizationRequests: true + }) + ).toThrow(new TypeError('Public PAR clients are not supported')); + }); }); diff --git a/tests/auth0-session/fixtures/server.ts b/tests/auth0-session/fixtures/server.ts index 50f2947dd..03cabaffa 100644 --- a/tests/auth0-session/fixtures/server.ts +++ b/tests/auth0-session/fixtures/server.ts @@ -187,6 +187,8 @@ export const setup = async ( nock('https://op.example.com').get('/.well-known/jwks.json').reply(200, jwks); + nock('https://op.example.com').post('/oauth/par').reply(201, { request_uri: 'foo', expires_in: 100 }); + nock('https://test.eu.auth0.com') .get('/.well-known/openid-configuration') .reply(200, { ...wellKnown, issuer: 'https://test.eu.auth0.com/', end_session_endpoint: undefined }); diff --git a/tests/auth0-session/fixtures/well-known.json b/tests/auth0-session/fixtures/well-known.json index e288a04f8..0c56ca9d0 100644 --- a/tests/auth0-session/fixtures/well-known.json +++ b/tests/auth0-session/fixtures/well-known.json @@ -2,6 +2,7 @@ "issuer": "https://op.example.com/", "authorization_endpoint": "https://op.example.com/authorize", "token_endpoint": "https://op.example.com/oauth/token", + "pushed_authorization_request_endpoint": "https://op.example.com/oauth/par", "userinfo_endpoint": "https://op.example.com/userinfo", "mfa_challenge_endpoint": "https://op.example.com/mfa/challenge", "jwks_uri": "https://op.example.com/.well-known/jwks.json", diff --git a/tests/auth0-session/handlers/login.test.ts b/tests/auth0-session/handlers/login.test.ts index a9f16ff87..43155c219 100644 --- a/tests/auth0-session/handlers/login.test.ts +++ b/tests/auth0-session/handlers/login.test.ts @@ -293,4 +293,30 @@ describe('login', () => { expect(cookie?.sameSite).toEqual('none'); expect(cookie?.secure).toBeTruthy(); }); + + it('should redirect to the authorize url for /login when "pushedAuthorizationRequests" is enabled', async () => { + const baseURL = await setup({ + ...defaultConfig, + clientSecret: '__test_client_secret__', + clientAuthMethod: 'client_secret_post', + pushedAuthorizationRequests: true + }); + const cookieJar = new CookieJar(); + + const { res } = await get(baseURL, '/login', { fullResponse: true, cookieJar }); + expect(res.statusCode).toEqual(302); + + const parsed = parse(res.headers.location, true); + expect(parsed).toMatchObject({ + host: 'op.example.com', + hostname: 'op.example.com', + pathname: '/authorize', + protocol: 'https:', + query: expect.objectContaining({ + request_uri: 'foo', + response_type: 'code', + scope: 'openid' + }) + }); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 4710fb8ea..288dcc24e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -91,7 +91,8 @@ describe('config params', () => { secure: true }, organization: undefined, - backchannelLogout: false + backchannelLogout: false, + pushedAuthorizationRequests: false }); }); diff --git a/tests/fixtures/app-router-helpers.ts b/tests/fixtures/app-router-helpers.ts index 2b46391f8..eb5d0fec7 100644 --- a/tests/fixtures/app-router-helpers.ts +++ b/tests/fixtures/app-router-helpers.ts @@ -62,6 +62,8 @@ export type GetResponseOpts = { clearNock?: boolean; auth0Instance?: Auth0Server; reqInit?: RequestInit; + parStatus?: number; + parPayload?: Record; }; export type LoginOpts = Omit; @@ -81,11 +83,13 @@ export const getResponse = async ({ extraHandlers, clearNock = true, auth0Instance, - reqInit + reqInit, + parStatus, + parPayload }: GetResponseOpts) => { const opts = { ...withApi, ...config }; clearNock && nock.cleanAll(); - await setupNock(opts, { idTokenClaims, discoveryOptions, userInfoPayload, userInfoToken }); + await setupNock(opts, { idTokenClaims, discoveryOptions, userInfoPayload, userInfoToken, parPayload, parStatus }); const auth0 = url.split('?')[0].split('/').slice(3); const instance = auth0Instance || initAuth0(opts); const handleAuth = instance.handleAuth({ diff --git a/tests/fixtures/oidc-nocks.ts b/tests/fixtures/oidc-nocks.ts index 6b8d12b99..95d11a5db 100644 --- a/tests/fixtures/oidc-nocks.ts +++ b/tests/fixtures/oidc-nocks.ts @@ -23,6 +23,7 @@ export function discovery(params: ConfigParameters, discoveryOptions?: any): noc token_endpoint: `${params.issuerBaseURL}/oauth/token`, userinfo_endpoint: `${params.issuerBaseURL}/userinfo`, jwks_uri: `${params.issuerBaseURL}/.well-known/jwks.json`, + pushed_authorization_request_endpoint: `${params.issuerBaseURL}/oauth/par`, scopes_supported: [ 'openid', 'profile', @@ -170,3 +171,7 @@ export function userInfo(params: ConfigParameters, token: string, payload: Recor .get('/userinfo') .reply(200, payload); } + +export function par(params: ConfigParameters, status = 201, payload: Record): nock.Scope { + return nock(`${params.issuerBaseURL}`).post('/oauth/par').reply(status, payload); +} diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts index 038ad0f98..33832743c 100644 --- a/tests/fixtures/setup.ts +++ b/tests/fixtures/setup.ts @@ -19,7 +19,7 @@ import { HandleProfile, HandleBackchannelLogout } from '../../src'; -import { codeExchange, discovery, jwksEndpoint, userInfo } from './oidc-nocks'; +import { codeExchange, discovery, jwksEndpoint, par, userInfo } from './oidc-nocks'; import { jwks, makeIdToken } from '../auth0-session/fixtures/cert'; import { start, stop } from './server'; import { encodeState } from '../../src/auth0-session/utils/encoding'; @@ -43,6 +43,8 @@ export type SetupOptions = { userInfoPayload?: Record; userInfoToken?: string; asyncProps?: boolean; + parStatus?: number; + parPayload?: Record; }; export const defaultOnError: PageRouterOnError = (_req, res, error) => { @@ -56,13 +58,19 @@ export const setupNock = async ( idTokenClaims, discoveryOptions, userInfoPayload = {}, - userInfoToken = 'eyJz93a...k4laUWw' - }: Pick = {} + userInfoToken = 'eyJz93a...k4laUWw', + parStatus = 201, + parPayload = { request_uri: 'foo', expires_in: 100 } + }: Pick< + SetupOptions, + 'idTokenClaims' | 'discoveryOptions' | 'userInfoPayload' | 'userInfoToken' | 'parStatus' | 'parPayload' + > = {} ) => { discovery(config, discoveryOptions); jwksEndpoint(config, jwks); codeExchange(config, await makeIdToken({ iss: 'https://acme.auth0.local/', ...idTokenClaims })); userInfo(config, userInfoToken, userInfoPayload); + par(config, parStatus, parPayload); }; export const setup = async ( diff --git a/tests/handlers/login-page-router.test.ts b/tests/handlers/login-page-router.test.ts index 44e19ee1a..1b0bd6d23 100644 --- a/tests/handlers/login-page-router.test.ts +++ b/tests/handlers/login-page-router.test.ts @@ -327,4 +327,31 @@ describe('login handler (page router)', () => { /Login handler failed. CAUSE: Custom state value must be an object/ ); }); + + test('should redirect to the identity provider', async () => { + const baseUrl = await setup({ + ...withoutApi, + clientSecret: '__test_client_secret__', + clientAuthMethod: 'client_secret_post', + pushedAuthorizationRequests: true + }); + const cookieJar = new CookieJar(); + const { + res: { statusCode, headers } + } = await get(baseUrl, '/api/auth/login', { cookieJar, fullResponse: true }); + + expect(statusCode).toBe(302); + expect(urlParse(headers.location, true)).toMatchObject({ + protocol: 'https:', + host: 'acme.auth0.local', + hash: null, + query: { + request_uri: 'foo', + response_type: 'code', + scope: 'openid', + client_id: '__test_client_id__' + }, + pathname: '/authorize' + }); + }); }); diff --git a/tests/handlers/login.test.ts b/tests/handlers/login.test.ts index 75be303f3..05ea42326 100644 --- a/tests/handlers/login.test.ts +++ b/tests/handlers/login.test.ts @@ -254,4 +254,40 @@ describe('login handler (app router)', () => { expect(res.status).toBe(500); expect(res.statusText).toMatch(/Login handler failed. CAUSE: Custom state value must be an object/); }); + + test('should redirect to the identity provider when using pushedAuthorizationRequests', async () => { + const res = await getResponse({ + url: '/api/auth/login', + config: { + clientSecret: '__test_client_secret__', + clientAuthMethod: 'client_secret_post', + pushedAuthorizationRequests: true + } + }); + expect(urlParse(res.headers.get('location'), true)).toMatchObject({ + protocol: 'https:', + host: 'acme.auth0.local', + hash: null, + query: { + request_uri: 'foo', + client_id: '__test_client_id__' + }, + pathname: '/authorize' + }); + }); + + test('should throw an error if the PAR endpoint returns an error', async () => { + const res = await getResponse({ + url: '/api/auth/login', + config: { + clientSecret: '__test_client_secret__', + clientAuthMethod: 'client_secret_post', + pushedAuthorizationRequests: true + }, + parStatus: 401, + parPayload: { error: 'invalid_client' } + }); + expect(res.ok).toBe(false); + expect(res.statusText).toBe('Login handler failed. CAUSE: invalid_client'); + }); });