From 6f40eec63a8c82783c3474decf2154d6a5f7cfdf Mon Sep 17 00:00:00 2001 From: Aleksander Date: Tue, 7 Aug 2018 22:27:09 +0200 Subject: [PATCH 01/10] Add UserInfo JWT response support --- index.d.ts | 1 + src/JoseUtil.js | 50 ++++++++-------- src/JsonService.js | 16 +++++- src/OidcClientSettings.js | 5 ++ src/UserInfoService.js | 116 +++++++++++++++++++++++++++++++++++++- 5 files changed, 161 insertions(+), 27 deletions(-) diff --git a/index.d.ts b/index.d.ts index 66b82dbf..0beb7b82 100644 --- a/index.d.ts +++ b/index.d.ts @@ -137,6 +137,7 @@ export interface OidcClientSettings { readonly staleStateAge?: number; readonly clockSkew?: number; readonly stateStore?: StateStore; + readonly userInfoJwtIssuer?: 'ANY' | 'OP' | string; ResponseValidatorCtor?: ResponseValidatorCtor; MetadataServiceCtor?: MetadataServiceCtor; extraQueryParams?: {}; diff --git a/src/JoseUtil.js b/src/JoseUtil.js index 1283caab..87d175d0 100644 --- a/src/JoseUtil.js +++ b/src/JoseUtil.js @@ -23,7 +23,7 @@ export class JoseUtil { } } - static validateJwt(jwt, key, issuer, audience, clockSkew, now) { + static validateJwt(jwt, key, issuer, audience, clockSkew, now, timeInsensitive) { Log.debug("JoseUtil.validateJwt"); try { @@ -54,7 +54,7 @@ export class JoseUtil { return Promise.reject(new Error("Unsupported key type: " + key && key.kty)); } - return JoseUtil._validateJwt(jwt, key, issuer, audience, clockSkew, now); + return JoseUtil._validateJwt(jwt, key, issuer, audience, clockSkew, now, timeInsensitive); } catch (e) { Log.error(e && e.message || e); @@ -62,7 +62,7 @@ export class JoseUtil { } } - static _validateJwt(jwt, key, issuer, audience, clockSkew, now) { + static _validateJwt(jwt, key, issuer, audience, clockSkew, now, timeInsensitive) { if (!clockSkew) { clockSkew = 0; } @@ -92,30 +92,32 @@ export class JoseUtil { return Promise.reject(new Error("Invalid audience in token: " + payload.aud)); } - var lowerNow = now + clockSkew; - var upperNow = now - clockSkew; + if (!timeInsensitive) { + var lowerNow = now + clockSkew; + var upperNow = now - clockSkew; - if (!payload.iat) { - Log.error("JoseUtil._validateJwt: iat was not provided"); - return Promise.reject(new Error("iat was not provided")); - } - if (lowerNow < payload.iat) { - Log.error("JoseUtil._validateJwt: iat is in the future", payload.iat); - return Promise.reject(new Error("iat is in the future: " + payload.iat)); - } + if (!payload.iat) { + Log.error("JoseUtil._validateJwt: iat was not provided"); + return Promise.reject(new Error("iat was not provided")); + } + if (lowerNow < payload.iat) { + Log.error("JoseUtil._validateJwt: iat is in the future", payload.iat); + return Promise.reject(new Error("iat is in the future: " + payload.iat)); + } - if (payload.nbf && lowerNow < payload.nbf) { - Log.error("JoseUtil._validateJwt: nbf is in the future", payload.nbf); - return Promise.reject(new Error("nbf is in the future: " + payload.nbf)); - } + if (payload.nbf && lowerNow < payload.nbf) { + Log.error("JoseUtil._validateJwt: nbf is in the future", payload.nbf); + return Promise.reject(new Error("nbf is in the future: " + payload.nbf)); + } - if (!payload.exp) { - Log.error("JoseUtil._validateJwt: exp was not provided"); - return Promise.reject(new Error("exp was not provided")); - } - if (payload.exp < upperNow) { - Log.error("JoseUtil._validateJwt: exp is in the past", payload.exp); - return Promise.reject(new Error("exp is in the past:" + payload.exp)); + if (!payload.exp) { + Log.error("JoseUtil._validateJwt: exp was not provided"); + return Promise.reject(new Error("exp was not provided")); + } + if (payload.exp < upperNow) { + Log.error("JoseUtil._validateJwt: exp is in the past", payload.exp); + return Promise.reject(new Error("exp is in the past:" + payload.exp)); + } } try { diff --git a/src/JsonService.js b/src/JsonService.js index 54076bc9..9a6352f3 100644 --- a/src/JsonService.js +++ b/src/JsonService.js @@ -5,7 +5,11 @@ import { Log } from './Log'; import { Global } from './Global'; export class JsonService { - constructor(additionalContentTypes = null, XMLHttpRequestCtor = Global.XMLHttpRequest) { + constructor( + additionalContentTypes = null, + XMLHttpRequestCtor = Global.XMLHttpRequest, + jwtHandler = null + ) { if (additionalContentTypes && Array.isArray(additionalContentTypes)) { this._contentTypes = additionalContentTypes.slice(); @@ -15,8 +19,12 @@ export class JsonService { this._contentTypes = []; } this._contentTypes.push('application/json'); + if (jwtHandler) { + this._contentTypes.push('application/jwt'); + } this._XMLHttpRequest = XMLHttpRequestCtor; + this._jwtHandler = jwtHandler; } getJson(url, token) { @@ -33,6 +41,7 @@ export class JsonService { req.open('GET', url); var allowedContentTypes = this._contentTypes; + var jwtHandler = this._jwtHandler; req.onload = function() { Log.debug("JsonService.getJson: HTTP response received, status", req.status); @@ -48,6 +57,11 @@ export class JsonService { } }); + if (found == "application/jwt") { + jwtHandler(req).then(resolve, reject); + return; + } + if (found) { try { resolve(JSON.parse(req.responseText)); diff --git a/src/OidcClientSettings.js b/src/OidcClientSettings.js index 60e9789c..d0d72dec 100644 --- a/src/OidcClientSettings.js +++ b/src/OidcClientSettings.js @@ -25,6 +25,7 @@ export class OidcClientSettings { // behavior flags filterProtocolClaims = true, loadUserInfo = true, staleStateAge = DefaultStaleStateAge, clockSkew = DefaultClockSkewInSeconds, + userInfoJwtIssuer = 'OP', // other behavior stateStore = new WebStorageStateStore(), ResponseValidatorCtor = ResponseValidator, @@ -56,6 +57,7 @@ export class OidcClientSettings { this._loadUserInfo = !!loadUserInfo; this._staleStateAge = staleStateAge; this._clockSkew = clockSkew; + this._userInfoJwtIssuer = userInfoJwtIssuer; this._stateStore = stateStore; this._validator = new ResponseValidatorCtor(this); @@ -173,6 +175,9 @@ export class OidcClientSettings { get clockSkew() { return this._clockSkew; } + get userInfoJwtIssuer() { + return this._userInfoJwtIssuer; + } get stateStore() { return this._stateStore; diff --git a/src/UserInfoService.js b/src/UserInfoService.js index eeea70ac..ba58aa74 100644 --- a/src/UserInfoService.js +++ b/src/UserInfoService.js @@ -4,17 +4,24 @@ import { JsonService } from './JsonService'; import { MetadataService } from './MetadataService'; import { Log } from './Log'; +import { JoseUtil } from './JoseUtil'; export class UserInfoService { - constructor(settings, JsonServiceCtor = JsonService, MetadataServiceCtor = MetadataService) { + constructor( + settings, + JsonServiceCtor = JsonService, + MetadataServiceCtor = MetadataService, + joseUtil = JoseUtil + ) { if (!settings) { Log.error("UserInfoService.ctor: No settings passed"); throw new Error("settings"); } this._settings = settings; - this._jsonService = new JsonServiceCtor(); + this._jsonService = new JsonServiceCtor(undefined, undefined, this._getClaimsFromJwt.bind(this)); this._metadataService = new MetadataServiceCtor(this._settings); + this._joseUtil = joseUtil; } getClaims(token) { @@ -32,4 +39,109 @@ export class UserInfoService { }); }); } + + _getClaimsFromJwt(req) { + try { + let jwt = this._joseUtil.parseJwt(req.responseText); + if (!jwt || !jwt.header || !jwt.payload) { + Log.error("UserInfoService._getClaimsFromJwt: Failed to parse JWT", jwt); + return Promise.reject(new Error("Failed to parse id_token")); + } + + var kid = jwt.header.kid; + + let issuerPromise; + switch (this._settings.userInfoJwtIssuer) { + case 'OP': + issuerPromise = this._metadataService.getIssuer(); + break; + case 'ANY': + issuerPromise = Promise.resolve(jwt.payload.iss); + break; + default: + issuerPromise = Promise.resolve(this._settings.userInfoJwtIssuer); + break; + } + + return issuerPromise.then(issuer => { + Log.debug("UserInfoService._getClaimsFromJwt: Received issuer:" + issuer); + + return this._metadataService.getSigningKeys().then(keys => { + if (!keys) { + Log.error("UserInfoService._getClaimsFromJwt: No signing keys from metadata"); + return Promise.reject(new Error("No signing keys from metadata")); + } + + Log.debug("UserInfoService._getClaimsFromJwt: Received signing keys"); + let key; + if (!kid) { + keys = this._filterByAlg(keys, jwt.header.alg); + + if (keys.length > 1) { + Log.error("UserInfoService._getClaimsFromJwt: No kid found in id_token and more than one key found in metadata"); + return Promise.reject(new Error("No kid found in id_token and more than one key found in metadata")); + } + else { + // kid is mandatory only when there are multiple keys in the referenced JWK Set document + // see http://openid.net/specs/openid-connect-core-1_0.html#Signing + key = keys[0]; + } + } + else { + key = keys.filter(key => { + return key.kid === kid; + })[0]; + } + + if (!key) { + Log.error("UserInfoService._getClaimsFromJwt: No key matching kid or alg found in signing keys"); + return Promise.reject(new Error("No key matching kid or alg found in signing keys")); + } + + let audience = this._settings.client_id; + + let clockSkewInSeconds = this._settings.clockSkew; + Log.debug("UserInfoService._getClaimsFromJwt: Validaing JWT; using clock skew (in seconds) of: ", clockSkewInSeconds); + + return this._joseUtil.validateJwt(req.responseText, key, issuer, audience, clockSkewInSeconds, undefined, true).then(() => { + Log.debug("UserInfoService._getClaimsFromJwt: JWT validation successful"); + return jwt.payload; + }); + }); + }); + return; + } + catch (e) { + Log.error("UserInfoService._getClaimsFromJwt: Error parsing JWT response", e.message); + reject(e); + return; + } + } + + _filterByAlg(keys, alg) { + var kty = null; + if (alg.startsWith("RS")) { + kty = "RSA"; + } + else if (alg.startsWith("PS")) { + kty = "PS"; + } + else if (alg.startsWith("ES")) { + kty = "EC"; + } + else { + Log.debug("UserInfoService._filterByAlg: alg not supported: ", alg); + return []; + } + + Log.debug("UserInfoService._filterByAlg: Looking for keys that match kty: ", kty); + + keys = keys.filter(key => { + return key.kty === kty; + }); + + Log.debug("UserInfoService._filterByAlg: Number of keys that match kty: ", kty, keys.length); + + return keys; + } } From 7a1efc0c068f887c8f0a78f57824e25933abc2cd Mon Sep 17 00:00:00 2001 From: Leon Machens Date: Wed, 19 Dec 2018 16:19:06 +0100 Subject: [PATCH 02/10] fix: window is not defined --- gulpfile.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gulpfile.js b/gulpfile.js index b177c69a..1fce17b9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -17,7 +17,9 @@ gulp.task('build-lib-sourcemap', ['jsrsasign'], function() { entry: npmEntry, output: { filename:'oidc-client.js', - libraryTarget:'umd' + libraryTarget:'umd', + // Workaround for https://github.com/webpack/webpack/issues/6642 + globalObject: 'this' }, plugins: [], devtool:'inline-source-map' @@ -34,6 +36,8 @@ gulp.task('build-lib-min', ['jsrsasign'], function() { output: { filename:'oidc-client.min.js', libraryTarget:'umd', + // Workaround for https://github.com/webpack/webpack/issues/6642 + globalObject: 'this' }, plugins: [], devtool: false, From 673dff39eb647b71db7afefc191b25fbf3a8198b Mon Sep 17 00:00:00 2001 From: Steven Liekens Date: Mon, 21 Jan 2019 17:43:13 +0100 Subject: [PATCH 03/10] remove duplicate dependency babel-polyfill is already listed in devDependencies so the entry for optionalDependencies is redundant --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index fbebc7ad..ffe66577 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,5 @@ "dependencies": { "jsrsasign": "^8.0.12" }, - "optionalDependencies": { - "babel-polyfill": ">=6.9.1" - }, "typings": "index.d.ts" } From 7855784857fbcd0277c17112b02515b76558f165 Mon Sep 17 00:00:00 2001 From: iliassk Date: Sat, 26 Jan 2019 23:29:25 +0100 Subject: [PATCH 04/10] Added support for extraQueryParams in the SignoutRequest --- src/OidcClient.js | 6 ++++-- src/SignoutRequest.js | 6 +++++- test/unit/SignoutRequest.spec.js | 9 +++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/OidcClient.js b/src/OidcClient.js index ed8df8a3..9f6efacd 100644 --- a/src/OidcClient.js +++ b/src/OidcClient.js @@ -123,12 +123,13 @@ export class OidcClient { }); } - createSignoutRequest({id_token_hint, data, state, post_logout_redirect_uri} = {}, + createSignoutRequest({id_token_hint, data, state, post_logout_redirect_uri, extraQueryParams } = {}, stateStore ) { Log.debug("OidcClient.createSignoutRequest"); post_logout_redirect_uri = post_logout_redirect_uri || this._settings.post_logout_redirect_uri; + extraQueryParams = extraQueryParams || this._settings.extraQueryParams; return this._metadataService.getEndSessionEndpoint().then(url => { if (!url) { @@ -142,7 +143,8 @@ export class OidcClient { url, id_token_hint, post_logout_redirect_uri, - data: data || state + data: data || state, + extraQueryParams }); var signoutState = request.state; diff --git a/src/SignoutRequest.js b/src/SignoutRequest.js index 0c429ee8..86f639c4 100644 --- a/src/SignoutRequest.js +++ b/src/SignoutRequest.js @@ -6,7 +6,7 @@ import { UrlUtility } from './UrlUtility'; import { State } from './State'; export class SignoutRequest { - constructor({url, id_token_hint, post_logout_redirect_uri, data}) { + constructor({url, id_token_hint, post_logout_redirect_uri, data, extraQueryParams}) { if (!url) { Log.error("SignoutRequest.ctor: No url passed"); throw new Error("url"); @@ -26,6 +26,10 @@ export class SignoutRequest { } } + for(let key in extraQueryParams){ + url = UrlUtility.addQueryParam(url, key, extraQueryParams[key]) + } + this.url = url; } } diff --git a/test/unit/SignoutRequest.spec.js b/test/unit/SignoutRequest.spec.js index f4cf85bb..bbd5391b 100644 --- a/test/unit/SignoutRequest.spec.js +++ b/test/unit/SignoutRequest.spec.js @@ -80,6 +80,15 @@ describe("SignoutRequest", function() { url.should.contain("state=" + subject.state.id); }); + it("should include extra query params", function() { + settings.extraQueryParams = { + 'TargetResource': 'logouturl.com', + 'InErrorResource': 'errorurl.com' + }; + subject = new SignoutRequest(settings); + subject.url.should.contain('TargetResource=logouturl.com&InErrorResource=errorurl.com'); + }); + }); }); From b7b78eeda00e028e602c020fe19a36cce39d979b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 5 Feb 2019 12:14:22 +0100 Subject: [PATCH 05/10] Fix User type --- index.d.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index 01178f1e..29f89ba7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -250,13 +250,14 @@ export class WebStorageStateStore implements StateStore { } export interface SigninResponse { - new (url: string): SigninResponse; + new (url: string, delimiter: string = '#'): SigninResponse; access_token: string; + code: string; error: string; error_description: string; error_uri: string; - expires_at: number; + expires_in: number; id_token: string; profile: any; scope: string; @@ -279,17 +280,21 @@ export interface SignoutResponse { state: any; } -export class User { - constructor(response: SigninResponse); - +export interface UserSettings { id_token: string; session_state: any; access_token: string; + refresh_token: string; token_type: string; scope: string; profile: any; expires_at: number; state: any; +} + +export class User extends UserSettings { + constructor(settings: UserSettings); + toStorageString(): string; readonly expires_in: number | undefined; From fad78dd7f4e3dc963881c1d787423efacebccee2 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 5 Feb 2019 13:12:20 +0100 Subject: [PATCH 06/10] Fix --- index.d.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 29f89ba7..ad473c81 100644 --- a/index.d.ts +++ b/index.d.ts @@ -292,9 +292,19 @@ export interface UserSettings { state: any; } -export class User extends UserSettings { +export class User { constructor(settings: UserSettings); + id_token: string; + session_state: any; + access_token: string; + refresh_token: string; + token_type: string; + scope: string; + profile: any; + expires_at: number; + state: any; + toStorageString(): string; readonly expires_in: number | undefined; From e6294fee8a20c1602401bc8fbb27c4628098185a Mon Sep 17 00:00:00 2001 From: Henrik Walker Moe Date: Fri, 8 Feb 2019 13:20:42 +0100 Subject: [PATCH 07/10] Adds failing test-case Given you want to merge two lists, when they share one claim but the claim contains common and an extra claim, and another claim in the second claim-object, then you get a result with duplicate claims lists with duplicate claims --- test/unit/ResponseValidator.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/unit/ResponseValidator.spec.js b/test/unit/ResponseValidator.spec.js index 0187b35c..bcc95059 100644 --- a/test/unit/ResponseValidator.spec.js +++ b/test/unit/ResponseValidator.spec.js @@ -538,6 +538,14 @@ describe("ResponseValidator", function () { }); + it("should merge claims when claim types are objects", function () { + + var c1 = { custom: {'apple': 'foo', 'pear': 'bar'} }; + var c2 = { custom: {'apple': 'foo', 'orange': 'peel'}, b: 'banana' }; + var result = subject._mergeClaims(c1, c2); + result.should.deep.equal({ custom: {'apple': 'foo', 'pear': 'bar', 'orange': 'peel'}, b: 'banana' }); + }); + it("should merge same claim types into array", function () { var c1 = { a: 'apple', b: 'banana' }; From 1300535d0a567d761797b9212d4c4a09b94f5d9a Mon Sep 17 00:00:00 2001 From: Henrik Walker Moe Date: Fri, 8 Feb 2019 13:22:07 +0100 Subject: [PATCH 08/10] Fixes failing test. Merges the left object with the current right object, then adding to the final result claim. --- src/ResponseValidator.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ResponseValidator.js b/src/ResponseValidator.js index 61979b02..47fb98e6 100644 --- a/src/ResponseValidator.js +++ b/src/ResponseValidator.js @@ -187,7 +187,12 @@ export class ResponseValidator { } } else if (result[name] !== value) { - result[name] = [result[name], value]; + if (typeof value === 'object') { + result[name] = this._mergeClaims(result[name], value); + } + else { + result[name] = [result[name], value]; + } } } } From 58af1542cc2a12d7ac3359bfd0daeea69d0bba5f Mon Sep 17 00:00:00 2001 From: Henrik Walker Moe Date: Fri, 8 Feb 2019 14:09:09 +0100 Subject: [PATCH 09/10] Fixes typo on variable 'required' `require` is undefined. This corrects the spelling of the variable that is used to call `_revokeAccessTokenInternal()` --- src/UserManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UserManager.js b/src/UserManager.js index 7e1291f2..6fdcb969 100644 --- a/src/UserManager.js +++ b/src/UserManager.js @@ -478,7 +478,7 @@ export class UserManager extends OidcClient { var access_token = user.access_token; var refresh_token = user.refresh_token; - return this._revokeAccessTokenInternal(access_token, require) + return this._revokeAccessTokenInternal(access_token, required) .then(atSuccess => { return this._revokeRefreshTokenInternal(refresh_token, required) .then(rtSuccess => { From a82ec664db161a01c5371d43bd59b05946076d1b Mon Sep 17 00:00:00 2001 From: Brock Allen Date: Sun, 10 Feb 2019 16:01:52 -0500 Subject: [PATCH 10/10] add open back for running local server --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index fbebc7ad..9cbc555c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "gulp-concat": "^2.6.1", "mocha": "^5.2.0", "natives": "^1.1.6", + "open": "0.0.5", "uglifyjs-webpack-plugin": "^1.2.7", "webpack": "^4.16.0", "webpack-stream": "^4.0.3"