diff --git a/docs/README.md b/docs/README.md index 712e83b7..b77fc3f9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -141,7 +141,7 @@ Performs [OpenID Provider Issuer Discovery][webfinger-discovery] based on End-Us - [Class: <Client>](#class-client) - - [new Client(metadata[, jwks])](#new-clientmetadata-jwks) + - [new Client(metadata[, jwks[, options]])](#new-clientmetadata-jwks-options) - [client.metadata](#clientmetadata) - [client.authorizationUrl(parameters)](#clientauthorizationurlparameters) - [client.endSessionUrl(parameters)](#clientendsessionurlparameters) @@ -157,7 +157,7 @@ Performs [OpenID Provider Issuer Discovery][webfinger-discovery] based on End-Us - [client.deviceAuthorization(parameters[, extras])](#clientdeviceauthorizationparameters-extras) - [Client Authentication Methods](#client-authentication-methods) - [Client.register(metadata[, other])](#clientregistermetadata-other) -- [Client.fromUri(registrationClientUri, registrationAccessToken[, jwks])](#clientfromuriregistrationclienturi-registrationaccesstoken-jwks) +- [Client.fromUri(registrationClientUri, registrationAccessToken[, jwks[, clientOptions]])](#clientfromuriregistrationclienturi-registrationaccesstoken-jwks-clientoptions) --- @@ -179,7 +179,7 @@ const { Issuer } = require('openid-client'); --- -#### `new Client(metadata[, jwks])` +#### `new Client(metadata[, jwks[, options]])` Creates a new Client with the provided metadata @@ -210,6 +210,9 @@ Creates a new Client with the provided metadata - other metadata may be present but currently doesn't have any special handling - `jwks`: `` JWK Set formatted object with private keys used for signing client assertions or decrypting responses. +- `options`: `` additional options for the client + - `additionalAuthorizedParties`: `` | `string[]` additional accepted values for the + Authorized Party (`azp`) claim. **Default:** only the client's client_id value is accepted. - Returns: `` --- @@ -487,10 +490,12 @@ Performs Dynamic Client Registration with the provided metadata at the issuer's public parts will be registered as `jwks`. - `initialAccessToken`: `` Initial Access Token to use as a Bearer token during the registration call. + - `additionalAuthorizedParties`: `` | `string[]` additional accepted values for the + Authorized Party (`azp`) claim. **Default:** only the client's client_id value is accepted. --- -#### `Client.fromUri(registrationClientUri, registrationAccessToken[, jwks])` +#### `Client.fromUri(registrationClientUri, registrationAccessToken[, jwks[, clientOptions]])` Performs Dynamic Client Read Request to retrieve a Client instance. @@ -499,6 +504,9 @@ Performs Dynamic Client Read Request to retrieve a Client instance. the Client Read Request - `jwks`: `` JWK Set formatted object with private keys used for signing client assertions or decrypting responses. +- `clientOptions`: `` additional options passed to the `Client` constructor + - `additionalAuthorizedParties`: `` | `string[]` additional accepted values for the + Authorized Party (`azp`) claim. **Default:** only the client's client_id value is accepted. --- diff --git a/lib/client.js b/lib/client.js index 46f4c1fe..85b24380 100644 --- a/lib/client.js +++ b/lib/client.js @@ -197,7 +197,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base * @name constructor * @api public */ - constructor(metadata = {}, jwks) { + constructor(metadata = {}, jwks, options) { super(); if (typeof metadata.client_id !== 'string' || !metadata.client_id) { @@ -230,6 +230,10 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base instance(this).set('keystore', keystore); } + if (options !== undefined) { + instance(this).set('options', options); + } + this[CLOCK_TOLERANCE] = 0; } @@ -903,11 +907,23 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base } } - if (payload.azp !== undefined && payload.azp !== this.client_id) { - throw new RPError({ - printf: ['azp must be the client_id, expected %s, got: %s', this.client_id, payload.azp], - jwt, - }); + if (payload.azp !== undefined) { + let { additionalAuthorizedParties } = instance(this).get('options') || {}; + + if (typeof additionalAuthorizedParties === 'string') { + additionalAuthorizedParties = [this.client_id, additionalAuthorizedParties]; + } else if (Array.isArray(additionalAuthorizedParties)) { + additionalAuthorizedParties = [this.client_id, ...additionalAuthorizedParties]; + } else { + additionalAuthorizedParties = [this.client_id]; + } + + if (!additionalAuthorizedParties.includes(payload.azp)) { + throw new RPError({ + printf: ['azp mismatch, got: %s', payload.azp], + jwt, + }); + } } let key; @@ -1389,12 +1405,14 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base * @name register * @api public */ - static async register(properties, { initialAccessToken, jwks } = {}) { + static async register(metadata, options = {}) { + const { initialAccessToken, jwks, ...clientOptions } = options; + assertIssuerConfiguration(this.issuer, 'registration_endpoint'); - if (jwks !== undefined && !(properties.jwks || properties.jwks_uri)) { + if (jwks !== undefined && !(metadata.jwks || metadata.jwks_uri)) { const keystore = getKeystore.call(this, jwks); - properties.jwks = keystore.toJWKS(false); + metadata.jwks = keystore.toJWKS(false); } const response = await request.call(this, { @@ -1402,13 +1420,13 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base Authorization: authorizationHeaderValue(initialAccessToken), } : undefined, json: true, - body: properties, + body: metadata, url: this.issuer.registration_endpoint, method: 'POST', }); const responseBody = processResponse(response, { statusCode: 201, bearer: true }); - return new this(responseBody, jwks); + return new this(responseBody, jwks, clientOptions); } /** @@ -1427,7 +1445,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base * @name fromUri * @api public */ - static async fromUri(registrationClientUri, registrationAccessToken, jwks) { + static async fromUri(registrationClientUri, registrationAccessToken, jwks, clientOptions) { const response = await request.call(this, { method: 'GET', url: registrationClientUri, @@ -1436,7 +1454,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base }); const responseBody = processResponse(response, { bearer: true }); - return new this(responseBody, jwks); + return new this(responseBody, jwks, clientOptions); } /** diff --git a/test/client/client_instance.test.js b/test/client/client_instance.test.js index 9456859a..719a8239 100644 --- a/test/client/client_instance.test.js +++ b/test/client/client_instance.test.js @@ -1845,6 +1845,14 @@ describe('Client', () => { client_id: 'identifier', client_secret: 'its gotta be a long secret and i mean at least 32 characters', }); + this.clientWith3rdParty = new this.issuer.Client({ + client_id: 'identifier', + client_secret: 'its gotta be a long secret and i mean at least 32 characters', + }, undefined, { additionalAuthorizedParties: 'authorized third party' }); + this.clientWith3rdParties = new this.issuer.Client({ + client_id: 'identifier', + client_secret: 'its gotta be a long secret and i mean at least 32 characters', + }, undefined, { additionalAuthorizedParties: ['authorized third party', 'another third party'] }); this.fapiClient = new this.issuer.FAPIClient({ client_id: 'identifier', @@ -1980,7 +1988,7 @@ describe('Client', () => { return this.IdToken(this.keystore.get(), 'RS256', payload) .then((token) => this.client.validateIdToken(token)) .then(fail, (error) => { - expect(error).to.have.property('message', 'azp must be the client_id, expected identifier, got: not the client'); + expect(error).to.have.property('message', 'azp mismatch, got: not the client'); }); }); @@ -2014,6 +2022,96 @@ describe('Client', () => { .then((token) => this.client.validateIdToken(token)); }); + it('rejects unknown additional party azp values (single additional value)', function () { + const payload = { + iss: this.issuer.issuer, + sub: 'userId', + aud: [this.client.client_id, 'someone else'], + azp: 'some unknown third party', + exp: now() + 3600, + iat: now(), + }; + + return this.IdToken(this.keystore.get(), 'RS256', payload) + .then((token) => this.clientWith3rdParty.validateIdToken(token)) + .then(fail, (error) => { + expect(error).to.have.property('message', 'azp mismatch, got: some unknown third party'); + }); + }); + + it('allows configured additional party azp value (single additional value)', function () { + const payload = { + iss: this.issuer.issuer, + sub: 'userId', + aud: [this.client.client_id, 'someone else'], + azp: 'authorized third party', + exp: now() + 3600, + iat: now(), + }; + + return this.IdToken(this.keystore.get(), 'RS256', payload) + .then((token) => this.clientWith3rdParty.validateIdToken(token)); + }); + + it('allows the default (client_id) additional party azp value (single additional value)', function () { + const payload = { + iss: this.issuer.issuer, + sub: 'userId', + aud: [this.client.client_id, 'someone else'], + azp: this.client.client_id, + exp: now() + 3600, + iat: now(), + }; + + return this.IdToken(this.keystore.get(), 'RS256', payload) + .then((token) => this.clientWith3rdParty.validateIdToken(token)); + }); + + it('rejects unknown additional party azp values (multiple additional values)', function () { + const payload = { + iss: this.issuer.issuer, + sub: 'userId', + aud: [this.client.client_id, 'someone else'], + azp: 'some unknown third party', + exp: now() + 3600, + iat: now(), + }; + + return this.IdToken(this.keystore.get(), 'RS256', payload) + .then((token) => this.clientWith3rdParties.validateIdToken(token)) + .then(fail, (error) => { + expect(error).to.have.property('message', 'azp mismatch, got: some unknown third party'); + }); + }); + + it('allows configured additional party azp value (multiple additional values)', function () { + const payload = { + iss: this.issuer.issuer, + sub: 'userId', + aud: [this.client.client_id, 'someone else'], + azp: 'authorized third party', + exp: now() + 3600, + iat: now(), + }; + + return this.IdToken(this.keystore.get(), 'RS256', payload) + .then((token) => this.clientWith3rdParties.validateIdToken(token)); + }); + + it('allows the default (client_id) additional party azp value (multiple additional values)', function () { + const payload = { + iss: this.issuer.issuer, + sub: 'userId', + aud: [this.client.client_id, 'someone else'], + azp: this.client.client_id, + exp: now() + 3600, + iat: now(), + }; + + return this.IdToken(this.keystore.get(), 'RS256', payload) + .then((token) => this.clientWith3rdParties.validateIdToken(token)); + }); + it('verifies the audience when string', function () { const payload = { iss: this.issuer.issuer, diff --git a/types/index.d.ts b/types/index.d.ts index 27cbe265..193889d6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -334,13 +334,17 @@ export interface IntrospectionResponse { [key: string]: unknown; } +export interface ClientOptions { + additionalAuthorizedParties?: string | string[]; +} + /** * Encapsulates a dynamically registered, discovered or instantiated OpenID Connect Client (Client), * Relying Party (RP), and its metadata, its instances hold the methods for getting an authorization URL, * consuming callbacks, triggering token endpoint grants, revoking and introspecting tokens. */ export class Client { - constructor(metadata: ClientMetadata, jwks?: JSONWebKeySet); + constructor(metadata: ClientMetadata, jwks?: JSONWebKeySet, options?: ClientOptions); [custom.http_options]: CustomHttpOptionsProvider; [custom.clock_tolerance]: number; metadata: ClientMetadata; @@ -453,8 +457,8 @@ export class Client { * for subsequent Device Access Token Request polling. */ deviceAuthorization(parameters?: DeviceAuthorizationParameters, extras?: DeviceAuthorizationExtras): Promise>; - static register(metadata: object, other?: RegisterOther): Promise; - static fromUri(registrationClientUri: string, registrationAccessToken: string, jwks?: JSONWebKeySet): Promise; + static register(metadata: object, other?: RegisterOther & ClientOptions): Promise; + static fromUri(registrationClientUri: string, registrationAccessToken: string, jwks?: JSONWebKeySet, clientOptions?: ClientOptions): Promise; static [custom.http_options]: CustomHttpOptionsProvider; [key: string]: unknown;