diff --git a/src/auth0-session/client/abstract-client.ts b/src/auth0-session/client/abstract-client.ts index 48f92edfd..54ca85f46 100644 --- a/src/auth0-session/client/abstract-client.ts +++ b/src/auth0-session/client/abstract-client.ts @@ -1,5 +1,5 @@ -import { Auth0Request } from '../http'; import { Config } from '../config'; +import { Auth0Request } from '../http'; export type Telemetry = { name: string; @@ -85,6 +85,7 @@ export interface AuthorizationParameters { } export abstract class AbstractClient { + constructor(protected config: Config, protected telemetry: Telemetry) {} abstract authorizationUrl(parameters: Record): Promise; abstract callbackParams(req: Auth0Request, expectedState: string): Promise; abstract callback( diff --git a/src/auth0-session/client/edge-client.ts b/src/auth0-session/client/edge-client.ts index 4d80ccb48..d8acd6a8f 100644 --- a/src/auth0-session/client/edge-client.ts +++ b/src/auth0-session/client/edge-client.ts @@ -26,17 +26,69 @@ const encodeBase64 = (input: string) => { }; export class EdgeClient extends AbstractClient { - constructor( - private client: oauth.Client, - private as: oauth.AuthorizationServer, - private config: Config, - private httpOptions: oauth.HttpRequestOptions - ) { - super(); + private client?: oauth.Client; + private as?: oauth.AuthorizationServer; + private httpOptions: () => oauth.HttpRequestOptions; + + constructor(protected config: Config, protected telemetry: Telemetry) { + super(config, telemetry); + if (config.authorizationParams.response_type !== 'code') { + throw new Error('This SDK only supports `response_type=code` when used in an Edge runtime.'); + } + + this.httpOptions = () => { + const headers = new Headers(); + if (config.enableTelemetry) { + const { name, version } = telemetry; + headers.set('User-Agent', `${name}/${version}`); + headers.set( + 'Auth0-Client', + encodeBase64( + JSON.stringify({ + name, + version, + env: { + edge: true + } + }) + ) + ); + } + return { + signal: AbortSignal.timeout(this.config.httpTimeout), + headers + }; + }; + } + + private async getClient(): Promise<[oauth.AuthorizationServer, oauth.Client]> { + if (this.as) { + return [this.as, this.client as oauth.Client]; + } + + const issuer = new URL(this.config.issuerBaseURL); + try { + this.as = await oauth + .discoveryRequest(issuer, this.httpOptions()) + .then((response) => oauth.processDiscoveryResponse(issuer, response)); + } catch (e) { + throw new DiscoveryError(e, this.config.issuerBaseURL); + } + + this.client = { + client_id: this.config.clientID, + ...(!this.config.clientAssertionSigningKey && { client_secret: this.config.clientSecret }), + token_endpoint_auth_method: this.config.clientAuthMethod, + id_token_signed_response_alg: this.config.idTokenSigningAlg, + [oauth.clockTolerance]: this.config.clockTolerance + }; + + return [this.as, this.client]; } async authorizationUrl(parameters: Record): Promise { - const authorizationUrl = new URL(this.as.authorization_endpoint as string); + const [as] = await this.getClient(); + const authorizationUrl = new URL(as.authorization_endpoint as string); authorizationUrl.searchParams.set('client_id', this.config.clientID); Object.entries(parameters).forEach(([key, value]) => { if (value === null || value === undefined) { @@ -48,11 +100,12 @@ export class EdgeClient extends AbstractClient { } async callbackParams(req: Auth0Request, expectedState: string) { + const [as, client] = await this.getClient(); const url = req.getMethod().toUpperCase() === 'GET' ? new URL(req.getUrl()) : new URLSearchParams(await req.getBody()); let result: ReturnType; try { - result = oauth.validateAuthResponse(this.as, this.client, url, expectedState); + result = oauth.validateAuthResponse(as, client, url, expectedState); } catch (e) { throw new ApplicationError(e); } @@ -72,6 +125,8 @@ export class EdgeClient extends AbstractClient { checks: OpenIDCallbackChecks, extras: CallbackExtras ): Promise { + const [as, client] = await this.getClient(); + const { clientAssertionSigningKey, clientAssertionSigningAlg } = this.config; let clientPrivateKey = clientAssertionSigningKey as CryptoKey | undefined; @@ -80,21 +135,21 @@ export class EdgeClient extends AbstractClient { clientPrivateKey = await jose.importPKCS8(clientPrivateKey, clientAssertionSigningAlg || 'RS256'); } const response = await oauth.authorizationCodeGrantRequest( - this.as, - this.client, + as, + client, parameters, redirectUri, checks.code_verifier as string, { additionalParameters: extras.exchangeBody, ...(clientPrivateKey && { clientPrivateKey }), - ...this.httpOptions + ...this.httpOptions() } ); const result = await oauth.processAuthorizationCodeOpenIDResponse( - this.as, - this.client, + as, + client, response, checks.nonce, checks.max_age @@ -110,14 +165,15 @@ export class EdgeClient extends AbstractClient { } async endSessionUrl(parameters: EndSessionParameters): Promise { - const issuerUrl = new URL(this.as.issuer); + const [as] = await this.getClient(); + const issuerUrl = new URL(as.issuer); if ( 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; - const auth0LogoutUrl: URL = new URL(urlJoin(this.as.issuer, '/v2/logout')); + 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); Object.entries(extraParams).forEach(([key, value]: [string, string]) => { @@ -128,10 +184,10 @@ export class EdgeClient extends AbstractClient { }); return auth0LogoutUrl.toString(); } - if (!this.as.end_session_endpoint) { + if (!as.end_session_endpoint) { throw new Error('RP Initiated Logout is not supported on your Authorization Server.'); } - const oidcLogoutUrl = new URL(this.as.end_session_endpoint); + const oidcLogoutUrl = new URL(as.end_session_endpoint); Object.entries(parameters).forEach(([key, value]: [string, string]) => { if (value === null || value === undefined) { return; @@ -144,21 +200,23 @@ export class EdgeClient extends AbstractClient { } async userinfo(accessToken: string): Promise> { - const response = await oauth.userInfoRequest(this.as, this.client, accessToken, this.httpOptions); + const [as, client] = await this.getClient(); + const response = await oauth.userInfoRequest(as, client, accessToken, this.httpOptions()); try { - return await oauth.processUserInfoResponse(this.as, this.client, oauth.skipSubjectCheck, response); + return await oauth.processUserInfoResponse(as, client, oauth.skipSubjectCheck, response); } catch (e) { throw new UserInfoError(e.message); } } async refresh(refreshToken: string, extras: { exchangeBody: Record }): Promise { - const res = await oauth.refreshTokenGrantRequest(this.as, this.client, refreshToken, { + const [as, client] = await this.getClient(); + const res = await oauth.refreshTokenGrantRequest(as, client, refreshToken, { additionalParameters: extras.exchangeBody, - ...this.httpOptions + ...this.httpOptions() }); - const result = await oauth.processRefreshTokenResponse(this.as, this.client, res); + const result = await oauth.processRefreshTokenResponse(as, client, res); if (oauth.isOAuth2Error(result)) { throw new AccessTokenError( AccessTokenErrorCode.FAILED_REFRESH_GRANT, @@ -190,51 +248,7 @@ export const clientGetter = (telemetry: Telemetry): ((config: Config) => Promise let client: EdgeClient; return async (config) => { if (!client) { - const headers = new Headers(); - if (config.enableTelemetry) { - const { name, version } = telemetry; - headers.set('User-Agent', `${name}/${version}`); - headers.set( - 'Auth0-Client', - encodeBase64( - JSON.stringify({ - name, - version, - env: { - edge: true - } - }) - ) - ); - } - const httpOptions: oauth.HttpRequestOptions = { - signal: AbortSignal.timeout(config.httpTimeout), - headers - }; - - if (config.authorizationParams.response_type !== 'code') { - throw new Error('This SDK only supports `response_type=code` when used in an Edge runtime.'); - } - - const issuer = new URL(config.issuerBaseURL); - let as: oauth.AuthorizationServer; - try { - as = await oauth - .discoveryRequest(issuer, httpOptions) - .then((response) => oauth.processDiscoveryResponse(issuer, response)); - } catch (e) { - throw new DiscoveryError(e, config.issuerBaseURL); - } - - const oauthClient: oauth.Client = { - client_id: config.clientID, - ...(!config.clientAssertionSigningKey && { client_secret: config.clientSecret }), - token_endpoint_auth_method: config.clientAuthMethod, - id_token_signed_response_alg: config.idTokenSigningAlg, - [oauth.clockTolerance]: config.clockTolerance - }; - - client = new EdgeClient(oauthClient, as, config, httpOptions); + client = new EdgeClient(config, telemetry); } return client; }; diff --git a/src/auth0-session/client/node-client.ts b/src/auth0-session/client/node-client.ts index da56a4b52..e4fa34c58 100644 --- a/src/auth0-session/client/node-client.ts +++ b/src/auth0-session/client/node-client.ts @@ -33,16 +33,142 @@ function sortSpaceDelimitedString(str: string): string { } export class NodeClient extends AbstractClient { - constructor(private client: Client) { - super(); + private client?: Client; + + private async getClient(): Promise { + if (this.client) { + return this.client; + } + const { + config, + telemetry: { name, version } + } = this; + + const defaultHttpOptions: CustomHttpOptionsProvider = (_url, options) => ({ + ...options, + headers: { + ...options.headers, + 'User-Agent': `${name}/${version}`, + ...(config.enableTelemetry + ? { + 'Auth0-Client': Buffer.from( + JSON.stringify({ + name, + version, + env: { + node: process.version + } + }) + ).toString('base64') + } + : undefined) + }, + timeout: config.httpTimeout, + agent: config.httpAgent + }); + const applyHttpOptionsCustom = (entity: Issuer | typeof Issuer | Client) => { + entity[custom.http_options] = defaultHttpOptions; + }; + + applyHttpOptionsCustom(Issuer); + let issuer: Issuer; + try { + issuer = await Issuer.discover(config.issuerBaseURL); + } catch (e) { + throw new DiscoveryError(e, config.issuerBaseURL); + } + applyHttpOptionsCustom(issuer); + + const issuerTokenAlgs = Array.isArray(issuer.id_token_signing_alg_values_supported) + ? issuer.id_token_signing_alg_values_supported + : []; + if (!issuerTokenAlgs.includes(config.idTokenSigningAlg)) { + debug( + 'ID token algorithm %o is not supported by the issuer. Supported ID token algorithms are: %o.', + config.idTokenSigningAlg, + issuerTokenAlgs + ); + } + + const configRespType = sortSpaceDelimitedString(config.authorizationParams.response_type); + const issuerRespTypes = Array.isArray(issuer.response_types_supported) ? issuer.response_types_supported : []; + issuerRespTypes.map(sortSpaceDelimitedString); + if (!issuerRespTypes.includes(configRespType)) { + debug( + 'Response type %o is not supported by the issuer. Supported response types are: %o.', + configRespType, + issuerRespTypes + ); + } + + const configRespMode = config.authorizationParams.response_mode; + const issuerRespModes = Array.isArray(issuer.response_modes_supported) ? issuer.response_modes_supported : []; + if (configRespMode && !issuerRespModes.includes(configRespMode)) { + debug( + 'Response mode %o is not supported by the issuer. Supported response modes are %o.', + configRespMode, + issuerRespModes + ); + } + + let jwks; + if (config.clientAssertionSigningKey) { + const privateKey = createPrivateKey({ key: config.clientAssertionSigningKey as string }); + const jwk = await exportJWK(privateKey); + jwks = { keys: [jwk] }; + } + + this.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 + ); + applyHttpOptionsCustom(this.client); + + this.client[custom.clock_tolerance] = config.clockTolerance; + const issuerUrl = new URL(issuer.metadata.issuer); + + if (config.idpLogout) { + if ( + this.config.idpLogout && + (this.config.auth0Logout || (issuerUrl.hostname.match('\\.auth0\\.com$') && this.config.auth0Logout !== false)) + ) { + Object.defineProperty(this.client, 'endSessionUrl', { + value(params: EndSessionParameters) { + const { id_token_hint, post_logout_redirect_uri, ...extraParams } = params; + 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); + Object.entries(extraParams).forEach(([key, value]) => { + if (value === null || value === undefined) { + return; + } + parsedUrl.searchParams.set(key, value as string); + }); + return parsedUrl.toString(); + } + }); + } else if (!issuer.end_session_endpoint) { + debug('the issuer does not support RP-Initiated Logout'); + } + } + + return this.client; } async authorizationUrl(parameters: Record): Promise { - return this.client.authorizationUrl(parameters); + const client = await this.getClient(); + return client.authorizationUrl(parameters); } async callbackParams(req: Auth0Request) { - const obj: CallbackParamsType = this.client.callbackParams({ + const client = await this.getClient(); + const obj: CallbackParamsType = client.callbackParams({ method: req.getMethod(), url: req.getUrl(), body: await req.getBody() @@ -57,8 +183,9 @@ export class NodeClient extends AbstractClient { extras: CallbackExtras ): Promise { const params = Object.fromEntries(parameters.entries()); + const client = await this.getClient(); try { - return await this.client.callback(redirectUri, params, checks, extras); + return await client.callback(redirectUri, params, checks, extras); } catch (err) { if (err instanceof errors.OPError) { throw new IdentityProviderError(err); @@ -72,20 +199,23 @@ export class NodeClient extends AbstractClient { } async endSessionUrl(parameters: EndSessionParameters): Promise { - return this.client.endSessionUrl(parameters); + const client = await this.getClient(); + return client.endSessionUrl(parameters); } async userinfo(accessToken: string): Promise> { + const client = await this.getClient(); try { - return await this.client.userinfo(accessToken); + return await client.userinfo(accessToken); } catch (e) { throw new UserInfoError(e.message); } } async refresh(refreshToken: string, extras: { exchangeBody: Record }): Promise { + const client = await this.getClient(); try { - return await this.client.refresh(refreshToken, extras); + return await client.refresh(refreshToken, extras); } catch (e) { throw new AccessTokenError( AccessTokenErrorCode.FAILED_REFRESH_GRANT, @@ -112,120 +242,7 @@ export const clientGetter = (telemetry: Telemetry): ((config: Config) => Promise let client: NodeClient; return async (config) => { if (!client) { - const { name, version } = telemetry; - - const defaultHttpOptions: CustomHttpOptionsProvider = (_url, options) => ({ - ...options, - headers: { - ...options.headers, - 'User-Agent': `${name}/${version}`, - ...(config.enableTelemetry - ? { - 'Auth0-Client': Buffer.from( - JSON.stringify({ - name, - version, - env: { - node: process.version - } - }) - ).toString('base64') - } - : undefined) - }, - timeout: config.httpTimeout, - agent: config.httpAgent - }); - const applyHttpOptionsCustom = (entity: Issuer | typeof Issuer | Client) => { - entity[custom.http_options] = defaultHttpOptions; - }; - - applyHttpOptionsCustom(Issuer); - let issuer: Issuer; - try { - issuer = await Issuer.discover(config.issuerBaseURL); - } catch (e) { - throw new DiscoveryError(e, config.issuerBaseURL); - } - applyHttpOptionsCustom(issuer); - - const issuerTokenAlgs = Array.isArray(issuer.id_token_signing_alg_values_supported) - ? issuer.id_token_signing_alg_values_supported - : []; - if (!issuerTokenAlgs.includes(config.idTokenSigningAlg)) { - debug( - 'ID token algorithm %o is not supported by the issuer. Supported ID token algorithms are: %o.', - config.idTokenSigningAlg, - issuerTokenAlgs - ); - } - - const configRespType = sortSpaceDelimitedString(config.authorizationParams.response_type); - const issuerRespTypes = Array.isArray(issuer.response_types_supported) ? issuer.response_types_supported : []; - issuerRespTypes.map(sortSpaceDelimitedString); - if (!issuerRespTypes.includes(configRespType)) { - debug( - 'Response type %o is not supported by the issuer. Supported response types are: %o.', - configRespType, - issuerRespTypes - ); - } - - const configRespMode = config.authorizationParams.response_mode; - const issuerRespModes = Array.isArray(issuer.response_modes_supported) ? issuer.response_modes_supported : []; - if (configRespMode && !issuerRespModes.includes(configRespMode)) { - debug( - 'Response mode %o is not supported by the issuer. Supported response modes are %o.', - configRespMode, - issuerRespModes - ); - } - - let jwks; - if (config.clientAssertionSigningKey) { - const privateKey = createPrivateKey({ key: config.clientAssertionSigningKey as string }); - const jwk = await exportJWK(privateKey); - jwks = { keys: [jwk] }; - } - - const oidcClient = 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 - ); - applyHttpOptionsCustom(oidcClient); - - oidcClient[custom.clock_tolerance] = config.clockTolerance; - const issuerUrl = new URL(issuer.metadata.issuer); - - if (config.idpLogout) { - if (config.auth0Logout || (issuerUrl.hostname.match('\\.auth0\\.com$') && config.auth0Logout !== false)) { - Object.defineProperty(oidcClient, 'endSessionUrl', { - value(params: EndSessionParameters) { - const { id_token_hint, post_logout_redirect_uri, ...extraParams } = params; - 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); - Object.entries(extraParams).forEach(([key, value]) => { - if (value === null || value === undefined) { - return; - } - parsedUrl.searchParams.set(key, value as string); - }); - return parsedUrl.toString(); - } - }); - } else if (!issuer.end_session_endpoint) { - debug('the issuer does not support RP-Initiated Logout'); - } - } - - client = new NodeClient(oidcClient); + client = new NodeClient(config, telemetry); } return client; }; diff --git a/tests/auth0-session/client/edge-client.test.ts b/tests/auth0-session/client/edge-client.test.ts index 5f37bc7a1..48b7805c9 100644 --- a/tests/auth0-session/client/edge-client.test.ts +++ b/tests/auth0-session/client/edge-client.test.ts @@ -8,7 +8,7 @@ import { jwks, makeIdToken } from '../fixtures/cert'; import pkg from '../../../package.json'; import wellKnown from '../fixtures/well-known.json'; import version from '../../../src/version'; -import { EdgeClient, clientGetter } from '../../../src/auth0-session/client/edge-client'; +import { EdgeClient } from '../../../src/auth0-session/client/edge-client'; import { mockFetch } from '../../fixtures/app-router-helpers'; import { Auth0Request } from '../../../src/auth0-session/http'; import { readFileSync } from 'fs'; @@ -50,14 +50,14 @@ const defaultConfig: ConfigParameters = { }; const getClient = async (params: ConfigParameters = {}): Promise => { - return clientGetter({ + return new EdgeClient(getConfig({ ...defaultConfig, ...params }), { name: 'nextjs-auth0', version - })(getConfig({ ...defaultConfig, ...params })); + }); }; describe('edge client', function () { - const headersSpy = jest.fn(); + let headersSpy = jest.fn(); beforeEach(() => { mockFetch(); @@ -147,7 +147,7 @@ describe('edge client', function () { idTokenSigningAlg: 'RS256' }); // @ts-ignore - expect(client.client.id_token_signed_response_alg).toEqual('RS256'); + expect((await client.getClient())[1].id_token_signed_response_alg).toEqual('RS256'); }); it('should use discovered logout endpoint by default', async function () { @@ -264,10 +264,14 @@ describe('edge client', function () { ); await expect( - getClient({ - issuerBaseURL: 'https://op2.example.com', - idpLogout: true - }) + ( + await getClient({ + issuerBaseURL: 'https://op2.example.com', + idpLogout: true + }) + ) + // @ts-ignore + .getClient() ).resolves.not.toThrow(); }); @@ -275,7 +279,9 @@ describe('edge client', function () { nock.cleanAll(); nock('https://op.example.com').get('/.well-known/oauth-authorization-server').reply(500); nock('https://op.example.com').get('/.well-known/openid-configuration').reply(500); - await expect(getClient()).rejects.toThrow(/Discovery requests failing for https:\/\/op.example.com/); + await expect((await getClient()).userinfo('token')).rejects.toThrow( + /Discovery requests failing for https:\/\/op.example.com/ + ); }); it('should throw UserInfoError when userinfo fails', async () => { diff --git a/tests/auth0-session/client/node-client.test.ts b/tests/auth0-session/client/node-client.test.ts index 1ba87dbfb..1aa983d51 100644 --- a/tests/auth0-session/client/node-client.test.ts +++ b/tests/auth0-session/client/node-client.test.ts @@ -4,7 +4,7 @@ import { jwks } from '../fixtures/cert'; import pkg from '../../../package.json'; import wellKnown from '../fixtures/well-known.json'; import version from '../../../src/version'; -import { NodeClient, clientGetter } from '../../../src/auth0-session/client/node-client'; +import { NodeClient } from '../../../src/auth0-session/client/node-client'; import { UserInfoError } from '../../../src/auth0-session/utils/errors'; const defaultConfig = { @@ -19,10 +19,10 @@ const defaultConfig = { }; const getClient = async (params: ConfigParameters = {}): Promise => { - return clientGetter({ + return new NodeClient(getConfig({ ...defaultConfig, ...params }), { name: 'nextjs-auth0', version - })(getConfig({ ...defaultConfig, ...params })); + }); }; describe('node client', function () { @@ -69,15 +69,6 @@ describe('node client', function () { expect(headerProps).not.toContain('auth0-client'); }); - it('should accept lazy config', async function () { - expect(() => - clientGetter({ - name: 'nextjs-auth0', - version - }) - ).not.toThrow(); - }); - it('should not strip new headers', async function () { const client = await getClient(); const response = await client.userinfo('__test_token__'); @@ -101,7 +92,7 @@ describe('node client', function () { idTokenSigningAlg: 'RS256' }); // @ts-ignore - expect((await client.client).id_token_signed_response_alg).toEqual('RS256'); + expect((await client.getClient()).id_token_signed_response_alg).toEqual('RS256'); }); it('should use discovered logout endpoint by default', async function () { @@ -203,10 +194,14 @@ describe('node client', function () { ); await expect( - getClient({ - issuerBaseURL: 'https://op2.example.com', - idpLogout: true - }) + ( + await getClient({ + issuerBaseURL: 'https://op2.example.com', + idpLogout: true + }) + ) + // @ts-ignore + .getClient() ).resolves.not.toThrow(); }); @@ -214,7 +209,7 @@ describe('node client', function () { nock.cleanAll(); nock('https://op.example.com').get('/.well-known/oauth-authorization-server').reply(500); nock('https://op.example.com').get('/.well-known/openid-configuration').reply(500); - await expect(getClient).rejects.toThrow( + await expect((await getClient()).userinfo('token')).rejects.toThrow( 'Discovery requests failing for https://op.example.com, expected 200 OK, got: 500 Internal Server Error' ); });