Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Pushed Authorization Requests #1598

Merged
merged 6 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
}

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 All @@ -99,7 +120,7 @@
return authorizationUrl.toString();
}

async callbackParams(req: Auth0Request, expectedState: string) {

Check warning on line 123 in src/auth0-session/client/edge-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

Missing return type on function
const [as, client] = await this.getClient();
const url =
req.getMethod().toUpperCase() === 'GET' ? new URL(req.getUrl()) : new URLSearchParams(await req.getBody());
Expand Down Expand Up @@ -172,7 +193,7 @@
this.config.idpLogout &&
(this.config.auth0Logout || (issuerUrl.hostname.match('\\.auth0\\.com$') && this.config.auth0Logout !== false))
) {
const { id_token_hint, post_logout_redirect_uri, ...extraParams } = parameters;

Check warning on line 196 in src/auth0-session/client/edge-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

'id_token_hint' is assigned a value but never used
const auth0LogoutUrl: URL = new URL(urlJoin(as.issuer, '/v2/logout'));
post_logout_redirect_uri && auth0LogoutUrl.searchParams.set('returnTo', post_logout_redirect_uri);
auth0LogoutUrl.searchParams.set('client_id', this.config.clientID);
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 @@
);
}

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 @@ -141,7 +147,7 @@
) {
Object.defineProperty(this.client, 'endSessionUrl', {
value(params: EndSessionParameters) {
const { id_token_hint, post_logout_redirect_uri, ...extraParams } = params;

Check warning on line 150 in src/auth0-session/client/node-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

'id_token_hint' is assigned a value but never used
const parsedUrl = new URL(urlJoin(issuer.metadata.issuer, '/v2/logout'));
parsedUrl.searchParams.set('client_id', config.clientID);
post_logout_redirect_uri && parsedUrl.searchParams.set('returnTo', post_logout_redirect_uri);
Expand All @@ -164,10 +170,16 @@

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);
}

async callbackParams(req: Auth0Request) {

Check warning on line 182 in src/auth0-session/client/node-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

Missing return type on function
const client = await this.getClient();
const obj: CallbackParamsType = client.callbackParams({
method: req.getMethod(),
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 @@
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 @@
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 @@
idTokenClaims,
discoveryOptions,
userInfoPayload = {},
userInfoToken = 'eyJz93a...k4laUWw'
}: Pick<SetupOptions, 'idTokenClaims' | 'discoveryOptions' | 'userInfoPayload' | 'userInfoToken'> = {}
userInfoToken = 'eyJz93a...k4laUWw',
Dismissed Show dismissed Hide dismissed
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
Loading