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, diff --git a/index.d.ts b/index.d.ts index 01178f1e..f820d038 100644 --- a/index.d.ts +++ b/index.d.ts @@ -140,6 +140,7 @@ export interface OidcClientSettings { readonly staleStateAge?: number; readonly clockSkew?: number; readonly stateStore?: StateStore; + readonly userInfoJwtIssuer?: 'ANY' | 'OP' | string; ResponseValidatorCtor?: ResponseValidatorCtor; MetadataServiceCtor?: MetadataServiceCtor; extraQueryParams?: {}; @@ -250,13 +251,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 +281,31 @@ export interface SignoutResponse { state: any; } +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 { - constructor(response: SigninResponse); + 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; diff --git a/package.json b/package.json index fbebc7ad..2cb30046 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" @@ -50,8 +51,5 @@ "dependencies": { "jsrsasign": "^8.0.12" }, - "optionalDependencies": { - "babel-polyfill": ">=6.9.1" - }, "typings": "index.d.ts" } diff --git a/src/JoseUtil.js b/src/JoseUtil.js index f97cae91..d213f9c4 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 validateJwtAttributes(jwt, issuer, audience, clockSkew, now) { + static validateJwtAttributes(jwt, issuer, audience, clockSkew, now, timeInsensitive) { if (!clockSkew) { clockSkew = 0; } @@ -96,38 +96,40 @@ export class JoseUtil { return Promise.reject(new Error("Invalid azp in token: " + payload.azp)); } - 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)); + } } return Promise.resolve(payload); } - static _validateJwt(jwt, key, issuer, audience, clockSkew, now) { + static _validateJwt(jwt, key, issuer, audience, clockSkew, now, timeInsensitive) { - return JoseUtil.validateJwtAttributes(jwt, issuer, audience, clockSkew, now).then(payload => { + return JoseUtil.validateJwtAttributes(jwt, issuer, audience, clockSkew, now, timeInsensitive).then(payload => { try { if (!jws.JWS.verify(jwt, key, AllowedSigningAlgs)) { Log.error("JoseUtil._validateJwt: signature validation failed"); diff --git a/src/JsonService.js b/src/JsonService.js index c7ed9df3..810d99e5 100644 --- a/src/JsonService.js +++ b/src/JsonService.js @@ -5,7 +5,11 @@ import { Log } from './Log.js'; import { Global } from './Global.js'; 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/OidcClient.js b/src/OidcClient.js index e006b229..e7910a2b 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/OidcClientSettings.js b/src/OidcClientSettings.js index 8583cb3a..1fdba17c 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, @@ -57,6 +58,7 @@ export class OidcClientSettings { this._loadUserInfo = !!loadUserInfo; this._staleStateAge = staleStateAge; this._clockSkew = clockSkew; + this._userInfoJwtIssuer = userInfoJwtIssuer; this._stateStore = stateStore; this._validator = new ResponseValidatorCtor(this); @@ -177,6 +179,9 @@ export class OidcClientSettings { get clockSkew() { return this._clockSkew; } + get userInfoJwtIssuer() { + return this._userInfoJwtIssuer; + } get stateStore() { return this._stateStore; diff --git a/src/ResponseValidator.js b/src/ResponseValidator.js index bb4172eb..425182cc 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]; + } } } } diff --git a/src/SignoutRequest.js b/src/SignoutRequest.js index eb2de5b9..96371013 100644 --- a/src/SignoutRequest.js +++ b/src/SignoutRequest.js @@ -6,7 +6,7 @@ import { UrlUtility } from './UrlUtility.js'; import { State } from './State.js'; 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/src/UserInfoService.js b/src/UserInfoService.js index 6e2d8918..d2a6f361 100644 --- a/src/UserInfoService.js +++ b/src/UserInfoService.js @@ -4,17 +4,24 @@ import { JsonService } from './JsonService.js'; import { MetadataService } from './MetadataService.js'; import { Log } from './Log.js'; +import { JoseUtil } from './JoseUtil.js'; 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; + } } diff --git a/src/UserManager.js b/src/UserManager.js index b07841be..3f4baf52 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 => { 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' }; 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'); + }); + }); });