diff --git a/README.md b/README.md index a4e1b9bc..52638846 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ openid-client. - [RFC7662 - OAuth 2.0 Token introspection][feature-introspection] - Client Authenticated request to token introspection +Updates to features defined in draft or experimental specifications are released as MINOR library +versions, if you utilize these consider using the tilde ~ operator in your package.json since +breaking changes may be introduced as part of these specification updates. ## Certification [OpenID Certification][openid-certified-link] @@ -254,6 +257,18 @@ userinfo also handles (as long as you have the proper metadata configured) respo - signed and encrypted (nested JWT) - just encrypted +### Getting RP-Initiated Logout url + +Note: Only usable with issuer's supporting OpenID Connect Session Management 1.0 + +```js +client.endSessionUrl({ + post_logout_redirect_uri: '...', // OPTIONAL, defaults to client.post_logout_redirect_uris[0] if there's only one + state: '...', // RECOMMENDED + id_token_hint: '...', // OPTIONAL, accepts the string value or tokenSet with id_token +}); // => String (URL) +``` + ### Fetching Distributed Claims ```js let claims = { diff --git a/lib/client.js b/lib/client.js index 4ad5f57e..ef426a7e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -94,6 +94,8 @@ function authorizationParams(params) { params ); + // TODO: default redirect_uris if there's one + forEach(authParams, (value, key) => { if (value === null || value === undefined) { delete authParams[key]; @@ -267,6 +269,44 @@ class Client { `; } + /** + * @name endSessionUrl + * @api public + */ + endSessionUrl(params = {}) { + assertIssuerConfiguration(this.issuer, 'end_session_endpoint'); + + const { + 0: postLogout, + length, + } = this.post_logout_redirect_uris || []; + + const { + post_logout_redirect_uri = length === 1 ? postLogout : undefined, + ...rest + } = params; + + let hint = params.id_token_hint; + + if (hint instanceof TokenSet) { + assert(hint.id_token, 'id_token not present in TokenSet'); + hint = hint.id_token; + } + + const target = url.parse(this.issuer.end_session_endpoint, true); + target.search = null; + target.query = Object.assign(rest, target.query, { + post_logout_redirect_uri, + id_token_hint: hint, + }); + forEach(target.query, (value, key) => { + if (value === null || value === undefined) { + delete target.query[key]; + } + }); + return url.format(target); + } + /** * @name callbackParams * @api public diff --git a/lib/passport_strategy.js b/lib/passport_strategy.js index 90b14a61..5eddd55b 100644 --- a/lib/passport_strategy.js +++ b/lib/passport_strategy.js @@ -64,7 +64,7 @@ function OpenIDConnectStrategy({ this.name = url.parse(client.issuer.issuer).hostname; if (!params.response_type) params.response_type = _.get(client, 'response_types[0]', 'code'); - if (!params.redirect_uri) params.redirect_uri = _.get(client, 'redirect_uris[0]'); + if (!params.redirect_uri) params.redirect_uri = _.get(client, 'redirect_uris[0]'); // TODO: only default if there's one if (!params.scope) params.scope = 'openid'; } diff --git a/test/client/client_instance.test.js b/test/client/client_instance.test.js index 3e495d6d..17d3e730 100644 --- a/test/client/client_instance.test.js +++ b/test/client/client_instance.test.js @@ -57,7 +57,7 @@ const encode = object => base64url.encode(JSON.stringify(object)); }); }); - it('keeps origin query parameters', function () { + it('keeps original query parameters', function () { expect(url.parse(this.clientWithQuery.authorizationUrl({ redirect_uri: 'https://rp.example.com/cb', }), true).query).to.eql({ @@ -120,6 +120,87 @@ const encode = object => base64url.encode(JSON.stringify(object)); }); }); + describe('#endSessionUrl', function () { + before(function () { + const issuer = new Issuer({ + end_session_endpoint: 'https://op.example.com/session/end', + }); + this.client = new issuer.Client({ + client_id: 'identifier', + }); + this.clientWithUris = new issuer.Client({ + post_logout_redirect_uris: ['https://rp.example.com/logout/cb'], + }); + + const issuerWithQuery = new Issuer({ + end_session_endpoint: 'https://op.example.com/session/end?foo=bar', + }); + this.clientWithQuery = new issuerWithQuery.Client({ + client_id: 'identifier', + }); + + const issuerWithoutMeta = new Issuer({ + // end_session_endpoint: 'https://op.example.com/session/end?foo=bar', + }); + this.clientWithoutMeta = new issuerWithoutMeta.Client({ + client_id: 'identifier', + }); + }); + + it("throws if the issuer doesn't have end_session_endpoint configured", function () { + expect(() => { + this.clientWithoutMeta.endSessionUrl(); + }).to.throw('end_session_endpoint must be configured on the issuer'); + }); + + it('returns the end_session_endpoint only if nothing is passed', function () { + expect(this.client.endSessionUrl()).to.eql('https://op.example.com/session/end'); + expect(this.clientWithQuery.endSessionUrl()).to.eql('https://op.example.com/session/end?foo=bar'); + }); + + it('defaults the post_logout_redirect_uri if client has some', function () { + expect(url.parse(this.clientWithUris.endSessionUrl(), true).query).to.eql({ + post_logout_redirect_uri: 'https://rp.example.com/logout/cb', + }); + }); + + it('takes a TokenSet too', function () { + const hint = new TokenSet({ + id_token: 'eyJhbGciOiJub25lIn0.eyJzdWIiOiJzdWJqZWN0In0.', + refresh_token: 'bar', + access_token: 'tokenValue', + }); + expect(url.parse(this.client.endSessionUrl({ + id_token_hint: hint, + }), true).query).to.eql({ + id_token_hint: 'eyJhbGciOiJub25lIn0.eyJzdWIiOiJzdWJqZWN0In0.', + }); + }); + + it('allows for recommended and optional query params to be passed in', function () { + expect(url.parse(this.client.endSessionUrl({ + post_logout_redirect_uri: 'https://rp.example.com/logout/cb', + state: 'foo', + id_token_hint: 'idtoken', + }), true).query).to.eql({ + post_logout_redirect_uri: 'https://rp.example.com/logout/cb', + state: 'foo', + id_token_hint: 'idtoken', + }); + expect(url.parse(this.clientWithQuery.endSessionUrl({ + post_logout_redirect_uri: 'https://rp.example.com/logout/cb', + state: 'foo', + id_token_hint: 'idtoken', + foo: 'this will be ignored', + }), true).query).to.eql({ + post_logout_redirect_uri: 'https://rp.example.com/logout/cb', + state: 'foo', + foo: 'bar', + id_token_hint: 'idtoken', + }); + }); + }); + describe('#authorizationPost', function () { const REGEXP = /name="(.+)" value="(.+)"/g;