Skip to content

Commit

Permalink
Add Pushed Authorization Requests (#1598)
Browse files Browse the repository at this point in the history
Co-authored-by: Adam Mcgrath <[email protected]>
  • Loading branch information
ewanharris and adamjmcgrath committed Dec 6, 2023
1 parent da3a19f commit d06d352
Show file tree
Hide file tree
Showing 18 changed files with 224 additions and 12 deletions.
1 change: 0 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion src/auth0-session/client/edge-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -87,7 +93,22 @@ export class EdgeClient extends AbstractClient {
}

async authorizationUrl(parameters: Record<string, unknown>): Promise<string> {
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<string, string>);
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]) => {
Expand Down
12 changes: 12 additions & 0 deletions src/auth0-session/client/node-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -164,6 +170,12 @@ export class NodeClient extends AbstractClient {

async authorizationUrl(parameters: Record<string, unknown>): Promise<string> {
const client = await this.getClient();

if (this.config.pushedAuthorizationRequests) {
const { request_uri } = await client.pushedAuthorizationRequest(parameters);
parameters = { request_uri };
}

return client.authorizationUrl(parameters);
}

Expand Down
8 changes: 8 additions & 0 deletions src/auth0-session/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions src/auth0-session/get-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand All @@ -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'), {
Expand All @@ -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<T> = {
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
23 changes: 22 additions & 1 deletion tests/auth0-session/client/edge-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const getClient = async (params: ConfigParameters = {}): Promise<EdgeClient> =>
};

describe('edge client', function () {
let headersSpy = jest.fn();
const headersSpy = jest.fn();

beforeEach(() => {
mockFetch();
Expand Down Expand Up @@ -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();
});
});
21 changes: 21 additions & 0 deletions tests/auth0-session/client/node-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});
});
10 changes: 10 additions & 0 deletions tests/auth0-session/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
});
2 changes: 2 additions & 0 deletions tests/auth0-session/fixtures/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions tests/auth0-session/fixtures/well-known.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions tests/auth0-session/handlers/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})
});
});
});
3 changes: 2 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ describe('config params', () => {
secure: true
},
organization: undefined,
backchannelLogout: false
backchannelLogout: false,
pushedAuthorizationRequests: false
});
});

Expand Down
8 changes: 6 additions & 2 deletions tests/fixtures/app-router-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export type GetResponseOpts = {
clearNock?: boolean;
auth0Instance?: Auth0Server;
reqInit?: RequestInit;
parStatus?: number;
parPayload?: Record<string, unknown>;
};

export type LoginOpts = Omit<GetResponseOpts, 'url'>;
Expand All @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/oidc-nocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, unknown>): nock.Scope {
return nock(`${params.issuerBaseURL}`).post('/oauth/par').reply(status, payload);
}
14 changes: 11 additions & 3 deletions tests/fixtures/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,6 +43,8 @@ export type SetupOptions = {
userInfoPayload?: Record<string, string>;
userInfoToken?: string;
asyncProps?: boolean;
parStatus?: number;
parPayload?: Record<string, unknown>;
};

export const defaultOnError: PageRouterOnError = (_req, res, error) => {
Expand All @@ -56,13 +58,19 @@ export const setupNock = async (
idTokenClaims,
discoveryOptions,
userInfoPayload = {},
userInfoToken = 'eyJz93a...k4laUWw'
}: Pick<SetupOptions, 'idTokenClaims' | 'discoveryOptions' | 'userInfoPayload' | 'userInfoToken'> = {}
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 (
Expand Down
27 changes: 27 additions & 0 deletions tests/handlers/login-page-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
});
});
Loading

0 comments on commit d06d352

Please sign in to comment.