From 310b0f28509121d7922d399599b2e14585e30ea9 Mon Sep 17 00:00:00 2001 From: pamapa Date: Thu, 28 Oct 2021 10:27:56 +0200 Subject: [PATCH] fix: #170 revert removing too much from commit "remove implicit flow" (320168b6) and adapt --- docs/migration.md | 3 +- docs/oidc-client-ts.api.md | 21 ++- package-lock.json | 13 +- package.json | 3 +- src/OidcClient.ts | 10 +- src/OidcClientSettings.ts | 6 +- src/ResponseValidator.ts | 78 +++++++++- src/SigninRequest.ts | 5 +- src/SigninResponse.ts | 4 +- src/SignoutRequest.ts | 7 +- src/User.ts | 5 +- src/UserInfoService.ts | 44 ++++++ src/UserManager.ts | 41 ++++- src/UserManagerSettings.ts | 6 +- src/utils/JwtUtils.ts | 29 ++++ src/utils/index.ts | 1 + test/unit/OidcClient.test.ts | 8 + test/unit/OidcClientSettings.test.ts | 39 +++++ test/unit/ResponseValidator.test.ts | 214 ++++++++++++++++++++++++++ test/unit/SigninRequest.test.ts | 11 ++ test/unit/SigninResponse.test.ts | 8 + test/unit/SignoutRequest.test.ts | 22 ++- test/unit/UserInfoService.test.ts | 100 ++++++++++++ test/unit/UserManager.test.ts | 3 + test/unit/UserManagerSettings.test.ts | 27 ++++ test/unit/utils/JwtUtils.test.ts | 54 +++++++ 26 files changed, 735 insertions(+), 27 deletions(-) create mode 100644 src/UserInfoService.ts create mode 100644 src/utils/JwtUtils.ts create mode 100644 test/unit/UserInfoService.test.ts create mode 100644 test/unit/utils/JwtUtils.test.ts diff --git a/docs/migration.md b/docs/migration.md index 6b6b61412..0ea98580e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -11,7 +11,6 @@ Ported library from JavaScript to TypeScript. - renamed staleStateAge to staleStateAgeInSeconds - removed ResponseValidatorCtor and MetadataServiceCtor, if needed OidcClient/UserManager class must be extended - changed response_type, only code flow (PKCE) is supported -- removed loadUserInfo **UserManagerSettings:** - renamed accessTokenExpiringNotificationTime to accessTokenExpiringNotificationTimeInSeconds @@ -19,8 +18,8 @@ Ported library from JavaScript to TypeScript. - changed checkSessionInterval (milliseconds) to checkSessionIntervalInSeconds - default of automaticSilentRenew changed from false to true - default of validateSubOnSilentRenew changed from false to true +- default of includeIdTokenInSilentRenew changed from true to false - default of monitorSession changed from true to false -- removed includeIdTokenInSilentRenew **UserManager:** - signoutPopupCallback to pass optionally keepOpen as true, second argument must be used diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md index 1c27a7ae3..80476e637 100644 --- a/docs/oidc-client-ts.api.md +++ b/docs/oidc-client-ts.api.md @@ -49,6 +49,8 @@ export interface CreateSigninRequestArgs { // (undocumented) extraTokenParams?: Record; // (undocumented) + id_token_hint?: string; + // (undocumented) login_hint?: string; // (undocumented) max_age?: number; @@ -174,11 +176,11 @@ export class OidcClient { // Warning: (ae-forgotten-export) The symbol "SigninRequest" needs to be exported by the entry point index.d.ts // // (undocumented) - createSigninRequest({ response_type, scope, redirect_uri, state, prompt, display, max_age, ui_locales, login_hint, acr_values, resource, request, request_uri, response_mode, extraQueryParams, extraTokenParams, request_type, skipUserInfo }: CreateSigninRequestArgs): Promise; + createSigninRequest({ response_type, scope, redirect_uri, state, prompt, display, max_age, ui_locales, id_token_hint, login_hint, acr_values, resource, request, request_uri, response_mode, extraQueryParams, extraTokenParams, request_type, skipUserInfo }: CreateSigninRequestArgs): Promise; // Warning: (ae-forgotten-export) The symbol "SignoutRequest" needs to be exported by the entry point index.d.ts // // (undocumented) - createSignoutRequest({ state, post_logout_redirect_uri, extraQueryParams, request_type }?: CreateSignoutRequestArgs): Promise; + createSignoutRequest({ state, id_token_hint, post_logout_redirect_uri, extraQueryParams, request_type }?: CreateSignoutRequestArgs): Promise; // (undocumented) readonly metadataService: MetadataService; // (undocumented) @@ -220,6 +222,7 @@ export interface OidcClientSettings { // (undocumented) extraTokenParams?: Record; filterProtocolClaims?: boolean; + loadUserInfo?: boolean; // (undocumented) max_age?: number; // (undocumented) @@ -318,6 +321,7 @@ export class TokenRevocationClient { // @public (undocumented) export class User { constructor(args: { + id_token?: string; session_state?: string; access_token: string; refresh_token?: string; @@ -337,6 +341,8 @@ export class User { set expires_in(value: number | undefined); // (undocumented) static fromStorageString(storageString: string): User; + // (undocumented) + id_token: string | undefined; // Warning: (ae-forgotten-export) The symbol "UserProfile" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -464,6 +470,8 @@ export class UserManager { protected _useRefreshToken(user: User): Promise; // (undocumented) protected get _userStoreKey(): string; + // (undocumented) + protected _validateIdTokenFromTokenRefreshToken(profile: UserProfile, id_token: string): Promise; } // @public (undocumented) @@ -524,6 +532,7 @@ export interface UserManagerSettings extends OidcClientSettings { accessTokenExpiringNotificationTimeInSeconds?: number; automaticSilentRenew?: boolean; checkSessionIntervalInSeconds?: number; + includeIdTokenInSilentRenew?: boolean; // (undocumented) monitorAnonymousSession?: boolean; monitorSession?: boolean; @@ -565,10 +574,10 @@ export class WebStorageStateStore implements StateStore { // Warnings were encountered during analysis: // -// src/OidcClient.ts:113:88 - (ae-forgotten-export) The symbol "SigninState" needs to be exported by the entry point index.d.ts -// src/OidcClient.ts:113:108 - (ae-forgotten-export) The symbol "SigninResponse" needs to be exported by the entry point index.d.ts -// src/OidcClient.ts:181:89 - (ae-forgotten-export) The symbol "State" needs to be exported by the entry point index.d.ts -// src/OidcClient.ts:181:115 - (ae-forgotten-export) The symbol "SignoutResponse" needs to be exported by the entry point index.d.ts +// src/OidcClient.ts:114:88 - (ae-forgotten-export) The symbol "SigninState" needs to be exported by the entry point index.d.ts +// src/OidcClient.ts:114:108 - (ae-forgotten-export) The symbol "SigninResponse" needs to be exported by the entry point index.d.ts +// src/OidcClient.ts:183:89 - (ae-forgotten-export) The symbol "State" needs to be exported by the entry point index.d.ts +// src/OidcClient.ts:183:115 - (ae-forgotten-export) The symbol "SignoutResponse" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/package-lock.json b/package-lock.json index 46b4b3751..921a77b54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "website" ], "dependencies": { - "crypto-js": "^4.1.1" + "crypto-js": "^4.1.1", + "jwt-decode": "^3.1.2" }, "devDependencies": { "@microsoft/api-extractor": "^7.18.10", @@ -15742,6 +15743,11 @@ "set-immediate-shim": "~1.0.1" } }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/karma": { "version": "6.3.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.4.tgz", @@ -40125,6 +40131,11 @@ "set-immediate-shim": "~1.0.1" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "karma": { "version": "6.3.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.4.tgz", diff --git a/package.json b/package.json index a69a18179..dae4eb308 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "prepare": "husky install" }, "dependencies": { - "crypto-js": "^4.1.1" + "crypto-js": "^4.1.1", + "jwt-decode": "^3.1.2" }, "devDependencies": { "@microsoft/api-extractor": "^7.18.10", diff --git a/src/OidcClient.ts b/src/OidcClient.ts index 5d2fde474..b9db1fed7 100644 --- a/src/OidcClient.ts +++ b/src/OidcClient.ts @@ -28,6 +28,7 @@ export interface CreateSigninRequestArgs { display?: string; max_age?: number; ui_locales?: string; + id_token_hint?: string; login_hint?: string; acr_values?: string; resource?: string; @@ -64,7 +65,7 @@ export class OidcClient { public async createSigninRequest({ response_type, scope, redirect_uri, state, - prompt, display, max_age, ui_locales, login_hint, acr_values, + prompt, display, max_age, ui_locales, id_token_hint, login_hint, acr_values, resource, request, request_uri, response_mode, extraQueryParams, extraTokenParams, request_type, skipUserInfo }: CreateSigninRequestArgs): Promise { Log.debug("OidcClient.createSigninRequest"); @@ -73,7 +74,7 @@ export class OidcClient { scope = scope || this.settings.scope; redirect_uri = redirect_uri || this.settings.redirect_uri; - // login_hint isn't allowed on _settings + // id_token_hint, login_hint aren't allowed on _settings prompt = prompt || this.settings.prompt; display = display || this.settings.display; max_age = max_age || this.settings.max_age; @@ -99,7 +100,7 @@ export class OidcClient { response_type, scope, state_data: state, - prompt, display, max_age, ui_locales, login_hint, acr_values, + prompt, display, max_age, ui_locales, id_token_hint, login_hint, acr_values, resource, request, request_uri, extraQueryParams, extraTokenParams, request_type, response_mode, client_secret: this.settings.client_secret, skipUserInfo @@ -146,7 +147,7 @@ export class OidcClient { public async createSignoutRequest({ state, - post_logout_redirect_uri, extraQueryParams, request_type + id_token_hint, post_logout_redirect_uri, extraQueryParams, request_type }: CreateSignoutRequestArgs = {}): Promise { Log.debug("OidcClient.createSignoutRequest"); @@ -163,6 +164,7 @@ export class OidcClient { const request = new SignoutRequest({ url, + id_token_hint, post_logout_redirect_uri, state_data: state, extraQueryParams, diff --git a/src/OidcClientSettings.ts b/src/OidcClientSettings.ts index b6b8abf9b..ea8c2069c 100644 --- a/src/OidcClientSettings.ts +++ b/src/OidcClientSettings.ts @@ -53,6 +53,8 @@ export interface OidcClientSettings { /** Should OIDC protocol claims be removed from profile (default: true) */ filterProtocolClaims?: boolean; + /** Flag to control if additional identity data is loaded from the user info endpoint in order to populate the user's profile (default: true) */ + loadUserInfo?: boolean; /** Number (in seconds) indicating the age of state entries in storage for authorize requests that are considered abandoned and thus can be cleaned up (default: 300) */ staleStateAgeInSeconds?: number; /** The window of time (in seconds) to allow the current time to deviate when validating token's iat, nbf, and exp values (default: 300) */ @@ -95,6 +97,7 @@ export class OidcClientSettingsStore { // behavior flags public readonly filterProtocolClaims: boolean | undefined; + public readonly loadUserInfo: boolean | undefined; public readonly staleStateAgeInSeconds: number; public readonly clockSkewInSeconds: number; public readonly userInfoJwtIssuer: "ANY" | "OP" | string | undefined; @@ -116,7 +119,7 @@ export class OidcClientSettingsStore { // optional protocol prompt, display, max_age, ui_locales, acr_values, resource, response_mode, // behavior flags - filterProtocolClaims = true, + filterProtocolClaims = true, loadUserInfo = true, staleStateAgeInSeconds = DefaultStaleStateAgeInSeconds, clockSkewInSeconds = DefaultClockSkewInSeconds, userInfoJwtIssuer = "OP", @@ -151,6 +154,7 @@ export class OidcClientSettingsStore { this.response_mode = response_mode; this.filterProtocolClaims = !!filterProtocolClaims; + this.loadUserInfo = !!loadUserInfo; this.staleStateAgeInSeconds = staleStateAgeInSeconds; this.clockSkewInSeconds = clockSkewInSeconds; this.userInfoJwtIssuer = userInfoJwtIssuer; diff --git a/src/ResponseValidator.ts b/src/ResponseValidator.ts index 34749736a..fcc994628 100644 --- a/src/ResponseValidator.ts +++ b/src/ResponseValidator.ts @@ -1,8 +1,9 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. -import { Log } from "./utils"; +import { Log, JwtUtils } from "./utils"; import type { MetadataService } from "./MetadataService"; +import { UserInfoService } from "./UserInfoService"; import { TokenClient } from "./TokenClient"; import { ErrorResponse } from "./ErrorResponse"; import type { OidcClientSettingsStore } from "./OidcClientSettings"; @@ -17,11 +18,13 @@ const ProtocolClaims = ["at_hash", "iat", "nbf", "exp", "aud", "iss", "c_hash"]; export class ResponseValidator { protected readonly _settings: OidcClientSettingsStore; protected readonly _metadataService: MetadataService; + protected readonly _userInfoService: UserInfoService; protected readonly _tokenClient: TokenClient; public constructor(settings: OidcClientSettingsStore, metadataService: MetadataService) { this._settings = settings; this._metadataService = metadataService; + this._userInfoService = new UserInfoService(metadataService); this._tokenClient = new TokenClient(this._settings, metadataService); } @@ -119,6 +122,26 @@ export class ResponseValidator { if (response.isOpenIdConnect) { Log.debug("ResponseValidator._processClaims: response is OIDC, processing claims"); response.profile = this._filterProtocolClaims(response.profile); + + if (state.skipUserInfo !== true && this._settings.loadUserInfo && response.access_token) { + Log.debug("ResponseValidator._processClaims: loading user info"); + + const claims = await this._userInfoService.getClaims(response.access_token); + Log.debug("ResponseValidator._processClaims: user info claims received from user info endpoint"); + + if (claims.sub !== response.profile.sub) { + Log.error("ResponseValidator._processClaims: sub from user info endpoint does not match sub in id_token"); + throw new Error("sub from user info endpoint does not match sub in id_token"); + } + + response.profile = this._mergeClaims(response.profile, claims); + Log.debug("ResponseValidator._processClaims: user info claims received, updated profile:", response.profile); + + return response; + } + else { + Log.debug("ResponseValidator._processClaims: not loading user info"); + } } else { Log.debug("ResponseValidator._processClaims: response is not OIDC, not processing claims"); @@ -127,6 +150,39 @@ export class ResponseValidator { return response; } + protected _mergeClaims(claims1: UserProfile, claims2: any): UserProfile { + const result = Object.assign({}, claims1 as Record); + + for (const name in claims2) { + let values = claims2[name]; + if (!Array.isArray(values)) { + values = [values]; + } + + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (!result[name]) { + result[name] = value; + } + else if (Array.isArray(result[name])) { + if (result[name].indexOf(value) < 0) { + result[name].push(value); + } + } + else if (result[name] !== value) { + if (typeof value === "object" && this._settings.mergeClaims) { + result[name] = this._mergeClaims(result[name], value); + } + else { + result[name] = [result[name], value]; + } + } + } + } + + return result; + } + protected _filterProtocolClaims(claims: UserProfile): UserProfile { Log.debug("ResponseValidator._filterProtocolClaims, incoming claims:", claims); @@ -175,13 +231,33 @@ export class ResponseValidator { response.error_description = tokenResponse.error_description || response.error_description; response.error_uri = tokenResponse.error_uri || response.error_uri; + response.id_token = tokenResponse.id_token || response.id_token; response.session_state = tokenResponse.session_state || response.session_state; response.access_token = tokenResponse.access_token || response.access_token; response.token_type = tokenResponse.token_type || response.token_type; response.scope = tokenResponse.scope || response.scope; response.expires_in = parseInt(tokenResponse.expires_in) || response.expires_in; + if (response.id_token) { + Log.debug("ResponseValidator._processCode: token response successful, processing id_token"); + return this._validateIdTokenAttributes(state, response, response.id_token); + } + Log.debug("ResponseValidator._processCode: token response successful, returning response"); return response; } + + protected async _validateIdTokenAttributes(state: SigninState, response: SigninResponse, id_token: string): Promise { + Log.debug("ResponseValidator._validateIdTokenAttributes: Decoding JWT attributes"); + + const payload = JwtUtils.decode(id_token); + + if (!payload.sub) { + Log.error("ResponseValidator._validateIdTokenAttributes: No sub present in id_token"); + throw new Error("No sub present in id_token"); + } + + response.profile = payload; + return response; + } } diff --git a/src/SigninRequest.ts b/src/SigninRequest.ts index 38fb0aa12..2d031a12d 100644 --- a/src/SigninRequest.ts +++ b/src/SigninRequest.ts @@ -19,6 +19,7 @@ export interface SigninRequestArgs { display?: string; max_age?: number; ui_locales?: string; + id_token_hint?: string; login_hint?: string; acr_values?: string; resource?: string; @@ -40,7 +41,7 @@ export class SigninRequest { // mandatory url, authority, client_id, redirect_uri, response_type, scope, // optional - state_data, prompt, display, max_age, ui_locales, login_hint, acr_values, resource, response_mode, + state_data, prompt, display, max_age, ui_locales, id_token_hint, login_hint, acr_values, resource, response_mode, request, request_uri, extraQueryParams, request_type, client_secret, extraTokenParams, skipUserInfo }: SigninRequestArgs) { if (!url) { @@ -93,7 +94,7 @@ export class SigninRequest { url = UrlUtils.addQueryParam(url, "code_challenge_method", "S256"); } - const optional: Record = { prompt, display, max_age, ui_locales, login_hint, acr_values, resource, request, request_uri, response_mode }; + const optional: Record = { prompt, display, max_age, ui_locales, id_token_hint, login_hint, acr_values, resource, request, request_uri, response_mode }; for (const key in optional) { if (optional[key]) { url = UrlUtils.addQueryParam(url, key, optional[key]); diff --git a/src/SigninResponse.ts b/src/SigninResponse.ts index 3c6b4f93c..dc7236b3f 100644 --- a/src/SigninResponse.ts +++ b/src/SigninResponse.ts @@ -18,6 +18,7 @@ export class SigninResponse { public error_uri: string | undefined; // updated by ResponseValidator + public id_token: string | undefined; public session_state: string | undefined; public access_token: string; public token_type: string; @@ -37,6 +38,7 @@ export class SigninResponse { this.code = values.code; this.state = values.state; + this.id_token = values.id_token; this.session_state = values.session_state; this.access_token = values.access_token; this.token_type = values.token_type; @@ -74,6 +76,6 @@ export class SigninResponse { } public get isOpenIdConnect(): boolean { - return this.scopes.indexOf(OidcScope) >= 0; + return this.scopes.indexOf(OidcScope) >= 0 || !!this.id_token; } } diff --git a/src/SignoutRequest.ts b/src/SignoutRequest.ts index a6e35ec68..8cba59d29 100644 --- a/src/SignoutRequest.ts +++ b/src/SignoutRequest.ts @@ -10,6 +10,7 @@ export interface SignoutRequestArgs { // optional state_data?: any; + id_token_hint?: string; post_logout_redirect_uri?: string; extraQueryParams?: Record; request_type?: string; @@ -21,13 +22,17 @@ export class SignoutRequest { public constructor({ url, - state_data, post_logout_redirect_uri, extraQueryParams, request_type + state_data, id_token_hint, post_logout_redirect_uri, extraQueryParams, request_type }: SignoutRequestArgs) { if (!url) { Log.error("SignoutRequest.ctor: No url passed"); throw new Error("url"); } + if (id_token_hint) { + url = UrlUtils.addQueryParam(url, "id_token_hint", id_token_hint); + } + if (post_logout_redirect_uri) { url = UrlUtils.addQueryParam(url, "post_logout_redirect_uri", post_logout_redirect_uri); diff --git a/src/User.ts b/src/User.ts index 47a0bf2f7..485d7ff71 100644 --- a/src/User.ts +++ b/src/User.ts @@ -15,6 +15,7 @@ export interface UserProfile { * @public */ export class User { + public id_token: string | undefined; public session_state: string | undefined; public access_token: string; public refresh_token: string | undefined; @@ -24,10 +25,11 @@ export class User { public expires_at: number | undefined; public constructor(args: { - session_state?: string; + id_token?: string; session_state?: string; access_token: string; refresh_token?: string; token_type: string; scope?: string; profile: UserProfile; expires_at?: number; }) { + this.id_token = args.id_token; this.session_state = args.session_state; this.access_token = args.access_token; this.refresh_token = args.refresh_token; @@ -67,6 +69,7 @@ export class User { public toStorageString(): string { Log.debug("User.toStorageString"); return JSON.stringify({ + id_token: this.id_token, session_state: this.session_state, access_token: this.access_token, refresh_token: this.refresh_token, diff --git a/src/UserInfoService.ts b/src/UserInfoService.ts new file mode 100644 index 000000000..fbe0fe737 --- /dev/null +++ b/src/UserInfoService.ts @@ -0,0 +1,44 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +import { Log, JwtUtils, JwtPayload } from "./utils"; +import { JsonService } from "./JsonService"; +import type { MetadataService } from "./MetadataService"; + +export class UserInfoService { + private _jsonService: JsonService; + private _metadataService: MetadataService; + + public constructor(metadataService: MetadataService) { + this._jsonService = new JsonService(undefined, this._getClaimsFromJwt); + this._metadataService = metadataService; + } + + public async getClaims(token: string): Promise { + if (!token) { + Log.error("UserInfoService.getClaims: No token passed"); + throw new Error("A token is required"); + } + + const url = await this._metadataService.getUserInfoEndpoint(); + Log.debug("UserInfoService.getClaims: received userinfo url", url); + + const claims = await this._jsonService.getJson(url, token); + Log.debug("UserInfoService.getClaims: claims received", claims); + + return claims; + } + + protected _getClaimsFromJwt = async (responseText: string): Promise => { + try { + const payload = JwtUtils.decode(responseText); + Log.debug("UserInfoService._getClaimsFromJwt: JWT decoding successful"); + + return payload; + } + catch (err) { + Log.error("UserInfoService._getClaimsFromJwt: Error parsing JWT response", err instanceof Error ? err.message : err); + throw err; + } + } +} diff --git a/src/UserManager.ts b/src/UserManager.ts index 26c773f68..33dc82580 100644 --- a/src/UserManager.ts +++ b/src/UserManager.ts @@ -1,15 +1,14 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. -import { Log } from "./utils"; +import { Log, JwtUtils } from "./utils"; import { IFrameNavigator, NavigateResponse, PopupNavigator, RedirectNavigator, PopupWindowParams, IWindow, IFrameWindowParams, RedirectParams } from "./navigators"; import { OidcClient, CreateSigninRequestArgs, CreateSignoutRequestArgs } from "./OidcClient"; import { UserManagerSettings, UserManagerSettingsStore } from "./UserManagerSettings"; -import { User } from "./User"; +import { User, UserProfile } from "./User"; import { UserManagerEvents } from "./UserManagerEvents"; import { SilentRenewService } from "./SilentRenewService"; import { SessionMonitor } from "./SessionMonitor"; -import { SigninRequest } from "./SigninRequest"; import { TokenRevocationClient } from "./TokenRevocationClient"; import { TokenClient } from "./TokenClient"; import type { SessionStatus } from "./SessionStatus"; @@ -213,6 +212,7 @@ export class UserManager { request_type: "si:s", redirect_uri: url, prompt: "none", + id_token_hint: this.settings.includeIdTokenInSilentRenew ? user?.id_token : undefined, ...requestArgs, }, handle, verifySub); if (user) { @@ -241,7 +241,12 @@ export class UserManager { throw new Error("No access token returned from token endpoint"); } + if (result.id_token) { + await this._validateIdTokenFromTokenRefreshToken(user.profile, result.id_token); + } + Log.debug("UserManager._useRefreshToken: refresh token response success"); + user.id_token = result.id_token || user.id_token; user.access_token = result.access_token || user.access_token; user.refresh_token = result.refresh_token || user.refresh_token; user.expires_in = result.expires_in; @@ -251,6 +256,30 @@ export class UserManager { return user; } + protected async _validateIdTokenFromTokenRefreshToken(profile: UserProfile, id_token: string): Promise { + const payload = JwtUtils.decode(id_token); + if (!payload) { + Log.error("UserManager._validateIdTokenFromTokenRefreshToken: Failed to decode id_token"); + throw new Error("Failed to decode id_token"); + } + if (payload.sub !== profile.sub) { + Log.error("UserManager._validateIdTokenFromTokenRefreshToken: sub in id_token does not match current sub"); + throw new Error("sub in id_token does not match current sub"); + } + if (payload.auth_time && payload.auth_time !== profile.auth_time) { + Log.error("UserManager._validateIdTokenFromTokenRefreshToken: auth_time in id_token does not match original auth_time"); + throw new Error("auth_time in id_token does not match original auth_time"); + } + if (payload.azp && payload.azp !== profile.azp) { + Log.error("UserManager._validateIdTokenFromTokenRefreshToken: azp in id_token does not match original azp"); + throw new Error("azp in id_token does not match original azp"); + } + if (!payload.azp && profile.azp) { + Log.error("UserManager._validateIdTokenFromTokenRefreshToken: azp not in id_token, but present in original id_token"); + throw new Error("azp not in id_token, but present in original id_token"); + } + } + public async signinSilentCallback(url?: string): Promise { await this._signinCallback(url, this._iframeNavigator); Log.info("UserManager.signinSilentCallback: successful"); @@ -454,6 +483,12 @@ export class UserManager { await this._revokeInternal(user); } + const id_token = args.id_token_hint || user && user.id_token; + if (id_token) { + Log.debug("UserManager._signoutStart: Setting id_token into signout request"); + args.id_token_hint = id_token; + } + await this.removeUser(); Log.debug("UserManager._signoutStart: user removed, creating signout request"); diff --git a/src/UserManagerSettings.ts b/src/UserManagerSettings.ts index a3723f69f..057c6a3cf 100644 --- a/src/UserManagerSettings.ts +++ b/src/UserManagerSettings.ts @@ -3,7 +3,6 @@ import { OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings"; import { WebStorageStateStore } from "./WebStorageStateStore"; -import { SigninRequest } from "./SigninRequest"; const DefaultAccessTokenExpiringNotificationTimeInSeconds = 60; const DefaultCheckSessionIntervalInSeconds = 2; @@ -32,6 +31,8 @@ export interface UserManagerSettings extends OidcClientSettings { automaticSilentRenew?: boolean; /** Flag to validate user.profile.sub in silent renew calls (default: true) */ validateSubOnSilentRenew?: boolean; + /** Flag to control if id_token is included as id_token_hint in silent renew calls (default: false) */ + includeIdTokenInSilentRenew?: boolean; /** Will raise events for when user has performed a signout at the OP (default: false) */ monitorSession?: boolean; @@ -61,6 +62,7 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore { public readonly silentRequestTimeoutInSeconds: number | undefined; public readonly automaticSilentRenew: boolean; public readonly validateSubOnSilentRenew: boolean; + public readonly includeIdTokenInSilentRenew: boolean; public readonly monitorSession: boolean; public readonly monitorAnonymousSession: boolean; @@ -85,6 +87,7 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore { silentRequestTimeoutInSeconds, automaticSilentRenew = true, validateSubOnSilentRenew = true, + includeIdTokenInSilentRenew = false, monitorSession = false, monitorAnonymousSession = false, @@ -110,6 +113,7 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore { this.silentRequestTimeoutInSeconds = silentRequestTimeoutInSeconds; this.automaticSilentRenew = automaticSilentRenew; this.validateSubOnSilentRenew = validateSubOnSilentRenew; + this.includeIdTokenInSilentRenew = includeIdTokenInSilentRenew; this.monitorSession = monitorSession; this.monitorAnonymousSession = monitorAnonymousSession; diff --git a/src/utils/JwtUtils.ts b/src/utils/JwtUtils.ts new file mode 100644 index 000000000..bd0d97430 --- /dev/null +++ b/src/utils/JwtUtils.ts @@ -0,0 +1,29 @@ +import jwt_decode from "jwt-decode"; + +import { Log } from "./Log"; + +export interface JwtPayload { + iss?: string; + aud?: string; + azp?: string; + iat?: number; + nbf?: number; + exp?: number; + sub?: string; + auth_time?: number; +} + +export class JwtUtils { + + // IMPORTANT: doesn't validate the token + public static decode(token: string): JwtPayload { + try { + const payload = jwt_decode(token); + return payload; + } + catch (err) { + Log.error(err); + throw err; + } + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 9e25350eb..b3594d82a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export * from "./CryptoUtils"; export * from "./Event"; +export * from "./JwtUtils"; export * from "./Log"; export * from "./Timer"; export * from "./UrlUtils"; diff --git a/test/unit/OidcClient.test.ts b/test/unit/OidcClient.test.ts index 8f63daafe..a9301494d 100644 --- a/test/unit/OidcClient.test.ts +++ b/test/unit/OidcClient.test.ts @@ -97,6 +97,7 @@ describe("OidcClient", () => { display: "d", max_age: 42, ui_locales: "u", + id_token_hint: "ith", login_hint: "lh", acr_values: "av", resource: "res", @@ -115,6 +116,7 @@ describe("OidcClient", () => { expect(url).toContain("display=d"); expect(url).toContain("max_age=42"); expect(url).toContain("ui_locales=u"); + expect(url).toContain("id_token_hint=ith"); expect(url).toContain("login_hint=lh"); expect(url).toContain("acr_values=av"); expect(url).toContain("resource=res"); @@ -137,6 +139,7 @@ describe("OidcClient", () => { display: "d", max_age: 42, ui_locales: "u", + id_token_hint: "ith", login_hint: "lh", acr_values: "av", resource: "res" @@ -153,6 +156,7 @@ describe("OidcClient", () => { expect(url).toContain("display=d"); expect(url).toContain("max_age=42"); expect(url).toContain("ui_locales=u"); + expect(url).toContain("id_token_hint=ith"); expect(url).toContain("login_hint=lh"); expect(url).toContain("acr_values=av"); expect(url).toContain("resource=res"); @@ -400,6 +404,7 @@ describe("OidcClient", () => { const request = await subject.createSignoutRequest({ state: "foo", post_logout_redirect_uri: "bar", + id_token_hint: "baz" }); // assert @@ -408,6 +413,7 @@ describe("OidcClient", () => { const url = request.url; expect(url).toContain("http://sts/signout"); expect(url).toContain("post_logout_redirect_uri=bar"); + expect(url).toContain("id_token_hint=baz"); }); it("should pass params to SignoutRequest", async () => { @@ -418,6 +424,7 @@ describe("OidcClient", () => { const request = await subject.createSignoutRequest({ state: "foo", post_logout_redirect_uri: "bar", + id_token_hint: "baz" }); // assert @@ -426,6 +433,7 @@ describe("OidcClient", () => { const url = request.url; expect(url).toContain("http://sts/signout"); expect(url).toContain("post_logout_redirect_uri=bar"); + expect(url).toContain("id_token_hint=baz"); }); it("should fail if metadata fails", async () => { diff --git a/test/unit/OidcClientSettings.test.ts b/test/unit/OidcClientSettings.test.ts index 9d5a512d6..6ffccc9f7 100644 --- a/test/unit/OidcClientSettings.test.ts +++ b/test/unit/OidcClientSettings.test.ts @@ -330,6 +330,45 @@ describe("OidcClientSettings", () => { }); }); + describe("loadUserInfo", () => { + + it("should use default value", () => { + // act + const subject = new OidcClientSettingsStore({ + authority: "authority", + client_id: "client", + redirect_uri: "redirect" + }); + + // assert + expect(subject.loadUserInfo).toEqual(true); + }); + + it("should return value from initial settings", () => { + // act + let subject = new OidcClientSettingsStore({ + authority: "authority", + client_id: "client", + redirect_uri: "redirect", + loadUserInfo: true + }); + + // assert + expect(subject.loadUserInfo).toEqual(true); + + // act + subject = new OidcClientSettingsStore({ + authority: "authority", + client_id: "client", + redirect_uri: "redirect", + loadUserInfo: false + }); + + // assert + expect(subject.loadUserInfo).toEqual(false); + }); + }); + describe("staleStateAge", () => { it("should use default value", () => { diff --git a/test/unit/ResponseValidator.test.ts b/test/unit/ResponseValidator.test.ts index 11efc716d..6123a4995 100644 --- a/test/unit/ResponseValidator.test.ts +++ b/test/unit/ResponseValidator.test.ts @@ -4,6 +4,7 @@ import { Log } from "../../src/utils"; import { ResponseValidator } from "../../src/ResponseValidator"; import { MetadataService } from "../../src/MetadataService"; +import type { UserInfoService } from "../../src/UserInfoService"; import { SigninState } from "../../src/SigninState"; import type { SigninResponse } from "../../src/SigninResponse"; import type { ErrorResponse } from "../../src/ErrorResponse"; @@ -16,6 +17,9 @@ class ResponseValidatorWrapper extends ResponseValidator { public async _processClaims(state: SigninState, response: SigninResponse) { return super._processClaims(state, response); } + public _mergeClaims(claims1: any, claims2: any) { + return super._mergeClaims(claims1, claims2); + } public _filterProtocolClaims(claims: any) { return super._filterProtocolClaims(claims); } @@ -25,6 +29,9 @@ class ResponseValidatorWrapper extends ResponseValidator { public async _processCode(state: SigninState, response: SigninResponse) { return super._processCode(state, response); } + public async _validateIdTokenAttributes(state: SigninState, response: SigninResponse, id_token: string) { + return super._validateIdTokenAttributes(state, response, id_token); + } } describe("ResponseValidator", () => { @@ -34,6 +41,7 @@ describe("ResponseValidator", () => { let subject: ResponseValidatorWrapper; let metadataService: MetadataService; + let userInfoService: UserInfoService; beforeEach(() => { Log.logger = console; @@ -59,6 +67,9 @@ describe("ResponseValidator", () => { jest.restoreAllMocks(); subject = new ResponseValidatorWrapper(settings, metadataService); + + // access private members + userInfoService = subject["_userInfoService"]; }); describe("validateSignoutResponse", () => { @@ -380,6 +391,209 @@ describe("ResponseValidator", () => { // assert expect(_filterProtocolClaimsMock).not.toBeCalled(); }); + + it("should load and merge user info claims when loadUserInfo configured", async () => { + // arrange + const state = new SigninState({ + authority: "authority", + client_id: "client", + redirect_uri: "http://cb", + scope: "scope", + request_type: "type" + }); + settings.loadUserInfo = true; + stubResponse.isOpenIdConnect = true; + stubResponse.profile = { a: "apple", b: "banana" }; + stubResponse.access_token = "access_token"; + const getClaimMock = jest.spyOn(userInfoService, "getClaims") + .mockImplementation(() => Promise.resolve({ c: "carrot" } as any)); + const _mergeClaimsMock = jest.spyOn(subject, "_mergeClaims") + .mockImplementation((profile) => profile); + + // act + await subject._processClaims(state, stubResponse); + + // assert + expect(getClaimMock).toBeCalled(); + expect(_mergeClaimsMock).toBeCalled(); + }); + + it("should not run if request was not openid", async () => { + // arrange + const state = new SigninState({ + authority: "authority", + client_id: "client", + redirect_uri: "http://cb", + scope: "scope", + request_type: "type" + }); + settings.loadUserInfo = true; + stubResponse.isOpenIdConnect = false; + stubResponse.profile = { a: "apple", b: "banana" }; + stubResponse.access_token = "access_token"; + const getClaimMock = jest.spyOn(userInfoService, "getClaims") + .mockImplementation(() => Promise.resolve({ c: "carrot" } as any)); + + // act + await subject._processClaims(state, stubResponse); + + // assert + expect(getClaimMock).not.toBeCalled(); + }); + + it("should not load and merge user info claims when loadUserInfo not configured", async () => { + // arrange + const state = new SigninState({ + authority: "authority", + client_id: "client", + redirect_uri: "http://cb", + scope: "scope", + request_type: "type" + }); + settings.loadUserInfo = false; + stubResponse.isOpenIdConnect = true; + stubResponse.profile = { a: "apple", b: "banana" }; + stubResponse.access_token = "access_token"; + const getClaimMock = jest.spyOn(userInfoService, "getClaims") + .mockImplementation(() => Promise.resolve({ c: "carrot" } as any)); + + // act + await subject._processClaims(state, stubResponse); + + // assert + expect(getClaimMock).not.toBeCalled(); + }); + + it("should not load user info claims if no access token", async () => { + // arrange + const state = new SigninState({ + authority: "authority", + client_id: "client", + redirect_uri: "http://cb", + scope: "scope", + request_type: "type" + }); + settings.loadUserInfo = true; + stubResponse.isOpenIdConnect = true; + stubResponse.profile = { a: "apple", b: "banana" }; + const getClaimMock = jest.spyOn(userInfoService, "getClaims") + .mockImplementation(() => Promise.resolve({ c: "carrot" } as any)); + + // act + await subject._processClaims(state, stubResponse); + + // assert + expect(getClaimMock).not.toBeCalled(); + }); + }); + + describe("_mergeClaims", () => { + + it("should merge claims", () => { + // arrange + const c1 = { a: "apple", b: "banana" }; + const c2 = { c: "carrot" }; + + // act + const result = subject._mergeClaims(c1, c2); + + // assert + expect(result).toEqual({ a: "apple", c: "carrot", b: "banana" }); + }); + + it("should not merge claims when claim types are objects", () => { + // arrange + const c1 = { custom: { "apple": "foo", "pear": "bar" } }; + const c2 = { custom: { "apple": "foo", "orange": "peel" }, b: "banana" }; + + // act + const result = subject._mergeClaims(c1, c2); + + // assert + expect(result).toEqual({ custom: [{ "apple": "foo", "pear": "bar" }, { "apple": "foo", "orange": "peel" }], b: "banana" }); + }); + + it("should merge claims when claim types are objects when mergeClaims settings is true", () => { + // arrange + settings.mergeClaims = true; + + const c1 = { custom: { "apple": "foo", "pear": "bar" } }; + const c2 = { custom: { "apple": "foo", "orange": "peel" }, b: "banana" }; + + // act + const result = subject._mergeClaims(c1, c2); + + // assert + expect(result).toEqual({ custom: { "apple": "foo", "pear": "bar", "orange": "peel" }, b: "banana" }); + }); + + it("should merge same claim types into array", () => { + // arrange + const c1 = { a: "apple", b: "banana" }; + const c2 = { a: "carrot" }; + + // act + const result = subject._mergeClaims(c1, c2); + + // assert + expect(result).toEqual({ a: ["apple", "carrot"], b: "banana" }); + }); + + it("should merge arrays of same claim types into array", () => { + // arrange + const c1 = { a: "apple", b: "banana" }; + const c2 = { a: ["carrot", "durian"] }; + + // act + let result = subject._mergeClaims(c1, c2); + + // assert + expect(result).toEqual({ a: ["apple", "carrot", "durian"], b: "banana" }); + + // arrange + const d1 = { a: ["apple", "carrot"], b: "banana" }; + const d2 = { a: ["durian"] }; + + // act + result = subject._mergeClaims(d1, d2); + + // assert + expect(result).toEqual({ a: ["apple", "carrot", "durian"], b: "banana" }); + + // arrange + const e1 = { a: ["apple", "carrot"], b: "banana" }; + const e2 = { a: "durian" }; + + // act + result = subject._mergeClaims(e1, e2); + + // assert + expect(result).toEqual({ a: ["apple", "carrot", "durian"], b: "banana" }); + }); + + it("should remove duplicates when producing arrays", () => { + // arrange + const c1 = { a: "apple", b: "banana" }; + const c2 = { a: ["apple", "durian"] }; + + // act + const result = subject._mergeClaims(c1, c2); + + // assert + expect(result).toEqual({ a: ["apple", "durian"], b: "banana" }); + }); + + it("should not add if already present in array", () => { + // arrange + const c1 = { a: ["apple", "durian"], b: "banana" }; + const c2 = { a: "apple" }; + + // act + const result = subject._mergeClaims(c1, c2); + + // assert + expect(result).toEqual({ a: ["apple", "durian"], b: "banana" }); + }); }); describe("_filterProtocolClaims", () => { diff --git a/test/unit/SigninRequest.test.ts b/test/unit/SigninRequest.test.ts index 4889fef8a..3dd99f738 100644 --- a/test/unit/SigninRequest.test.ts +++ b/test/unit/SigninRequest.test.ts @@ -190,6 +190,17 @@ describe("SigninRequest", () => { expect(subject.url).toContain("ui_locales=foo"); }); + it("should include id_token_hint", () => { + // arrange + settings.id_token_hint = "foo"; + + // act + subject = new SigninRequest(settings); + + // assert + expect(subject.url).toContain("id_token_hint=foo"); + }); + it("should include login_hint", () => { // arrange settings.login_hint = "foo"; diff --git a/test/unit/SigninResponse.test.ts b/test/unit/SigninResponse.test.ts index 3cae59f8e..be668fb07 100644 --- a/test/unit/SigninResponse.test.ts +++ b/test/unit/SigninResponse.test.ts @@ -48,6 +48,14 @@ describe("SigninResponse", () => { expect(subject.code).toEqual("foo"); }); + it("should read id_token", () => { + // act + const subject = new SigninResponse("id_token=foo"); + + // assert + expect(subject.id_token).toEqual("foo"); + }); + it("should read session_state", () => { // act const subject = new SigninResponse("session_state=foo"); diff --git a/test/unit/SignoutRequest.test.ts b/test/unit/SignoutRequest.test.ts index 0850b4766..a8eef281e 100644 --- a/test/unit/SignoutRequest.test.ts +++ b/test/unit/SignoutRequest.test.ts @@ -11,6 +11,7 @@ describe("SignoutRequest", () => { beforeEach(() => { settings = { url: "http://sts/signout", + id_token_hint: "hint", post_logout_redirect_uri: "loggedout", state_data: { data: "test" } }; @@ -42,7 +43,23 @@ describe("SignoutRequest", () => { expect(subject.url.indexOf("http://sts/signout")).toEqual(0); }); - it("should include post_logout_redirect_uri", () => { + it("should include id_token_hint", () => { + // assert + expect(subject.url).toContain("id_token_hint=hint"); + }); + + it("should include post_logout_redirect_uri if id_token_hint also provided", () => { + // assert + expect(subject.url).toContain("post_logout_redirect_uri=loggedout"); + }); + + it("should include post_logout_redirect_uri if no id_token_hint provided", () => { + // arrange + delete settings.id_token_hint; + + // act + subject = new SignoutRequest(settings); + // assert expect(subject.url).toContain("post_logout_redirect_uri=loggedout"); }); @@ -65,10 +82,11 @@ describe("SignoutRequest", () => { expect(subject.url).not.toContain("state="); }); - it("should include post_logout_redirect_uri, and state", () => { + it("should include id_token_hint, post_logout_redirect_uri, and state", () => { // assert const url = subject.url; expect(url.indexOf("http://sts/signout?")).toEqual(0); + expect(url).toContain("id_token_hint=hint"); expect(url).toContain("post_logout_redirect_uri=loggedout"); expect(subject.state).toBeDefined(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/test/unit/UserInfoService.test.ts b/test/unit/UserInfoService.test.ts new file mode 100644 index 000000000..a0b2edb14 --- /dev/null +++ b/test/unit/UserInfoService.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +import { UserInfoService } from "../../src/UserInfoService"; +import { MetadataService } from "../../src/MetadataService"; +import type { JsonService } from "../../src/JsonService"; +import { OidcClientSettingsStore } from "../../src/OidcClientSettings"; + +describe("UserInfoService", () => { + let subject: UserInfoService; + let metadataService: MetadataService; + let jsonService: JsonService; + + beforeEach(() => { + const settings = new OidcClientSettingsStore({ + authority: "authority", + client_id: "client", + redirect_uri: "redirect" + }); + metadataService = new MetadataService(settings); + + subject = new UserInfoService(metadataService); + + // access private members + jsonService = subject["_jsonService"]; + }); + + describe("getClaims", () => { + + it("should return a promise", async () => { + // act + const p = subject.getClaims(""); + + // assert + expect(p).toBeInstanceOf(Promise); + // eslint-disable-next-line no-empty + try { await p; } catch {} + }); + + it("should require a token", async () => { + // act + try { + await subject.getClaims(""); + fail("should not come here"); + } + catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("token"); + } + }); + + it("should call userinfo endpoint and pass token", async () => { + // arrange + jest.spyOn(metadataService, "getUserInfoEndpoint").mockImplementation(() => Promise.resolve("http://sts/userinfo")); + const getJsonMock = jest.spyOn(jsonService, "getJson") + .mockImplementation(() => Promise.resolve("test")); + + // act + await subject.getClaims("token"); + + // assert + expect(getJsonMock).toBeCalledWith("http://sts/userinfo", "token"); + }); + + it("should fail when dependencies fail", async () => { + // arrange + jest.spyOn(metadataService, "getUserInfoEndpoint").mockRejectedValue(new Error("test")); + + // act + try { + await subject.getClaims("token"); + fail("should not come here"); + } + catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("test"); + } + }); + + it("should return claims", async () => { + // arrange + jest.spyOn(metadataService, "getUserInfoEndpoint").mockImplementation(() => Promise.resolve("http://sts/userinfo")); + const expectedClaims = { + foo: 1, bar: "test", + aud:"some_aud", iss:"issuer", + sub:"123", email:"foo@gmail.com", + role:["admin", "dev"], + nonce:"nonce", at_hash:"athash", + iat:5, nbf:10, exp:20 + }; + jest.spyOn(jsonService, "getJson").mockImplementation(() => Promise.resolve(expectedClaims)); + + // act + const claims = await subject.getClaims("token"); + + // assert + expect(claims).toEqual(expectedClaims); + }); + }); +}); diff --git a/test/unit/UserManager.test.ts b/test/unit/UserManager.test.ts index a27ba8a10..7a483235c 100644 --- a/test/unit/UserManager.test.ts +++ b/test/unit/UserManager.test.ts @@ -52,6 +52,7 @@ describe("UserManager", () => { it("should be able to call getUser without recursion", () => { // arrange const user = new User({ + id_token: "id_token", access_token: "access_token", token_type: "token_type", profile: {} @@ -75,6 +76,7 @@ describe("UserManager", () => { it("should pass silentRequestTimeout from settings", async () => { // arrange const user = new User({ + id_token: "id_token", access_token: "access_token", token_type: "token_type", profile: {} @@ -106,6 +108,7 @@ describe("UserManager", () => { it("should pass silentRequestTimeout from params", async () => { // arrange const user = new User({ + id_token: "id_token", access_token: "access_token", token_type: "token_type", profile: {} diff --git a/test/unit/UserManagerSettings.test.ts b/test/unit/UserManagerSettings.test.ts index 0ea24afea..7123d2447 100644 --- a/test/unit/UserManagerSettings.test.ts +++ b/test/unit/UserManagerSettings.test.ts @@ -188,6 +188,33 @@ describe("UserManagerSettings", () => { }); + describe("includeIdTokenInSilentRenew", () => { + it("should return true value from initial settings", () => { + // act + const subject = new UserManagerSettingsStore({ + authority: "authority", + client_id: "client", + redirect_uri: "redirect", + includeIdTokenInSilentRenew: true, + }); + + // assert + expect(subject.includeIdTokenInSilentRenew).toEqual(true); + }); + + it("should use default value", () => { + // act + const subject = new UserManagerSettingsStore({ + authority: "authority", + client_id: "client", + redirect_uri: "redirect" + }); + + // assert + expect(subject.includeIdTokenInSilentRenew).toEqual(false); + }); + }); + describe("accessTokenExpiringNotificationTime", () => { it("should return value from initial settings", () => { diff --git a/test/unit/utils/JwtUtils.test.ts b/test/unit/utils/JwtUtils.test.ts new file mode 100644 index 000000000..320abe9fb --- /dev/null +++ b/test/unit/utils/JwtUtils.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +import { Log, JwtUtils } from "../../../src/utils"; + +describe("JwtUtils", () => { + + let jwt: string; + + beforeEach(() => { + Log.logger = console; + Log.level = Log.NONE; + + jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImEzck1VZ01Gdjl0UGNsTGE2eUYzekFrZnF1RSIsImtpZCI6ImEzck1VZ01Gdjl0UGNsTGE2eUYzekFrZnF1RSJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo0NDMzMy9jb3JlIiwiYXVkIjoianMudG9rZW5tYW5hZ2VyIiwiZXhwIjoxNDU5MTMwMjAxLCJuYmYiOjE0NTkxMjk5MDEsIm5vbmNlIjoiNzIyMTAwNTIwOTk3MjM4MiIsImlhdCI6MTQ1OTEyOTkwMSwiYXRfaGFzaCI6IkpnRFVDeW9hdEp5RW1HaWlXYndPaEEiLCJzaWQiOiIwYzVmMDYxZTYzOThiMWVjNmEwYmNlMmM5NDFlZTRjNSIsInN1YiI6Ijg4NDIxMTEzIiwiYXV0aF90aW1lIjoxNDU5MTI5ODk4LCJpZHAiOiJpZHNydiIsImFtciI6WyJwYXNzd29yZCJdfQ.f6S1Fdd0UQScZAFBzXwRiVsUIPQnWZLSe07kdtjANRZDZXf5A7yDtxOftgCx5W0ONQcDFVpLGPgTdhp7agZkPpCFutzmwr0Rr9G7E7mUN4xcIgAABhmRDfzDayFBEu6VM8wEWTChezSWtx2xG_2zmVJxxmNV0jvkaz0bu7iin-C_UZg6T-aI9FZDoKRGXZP9gF65FQ5pQ4bCYQxhKcvjjUfs0xSHGboL7waN6RfDpO4vvVR1Kz-PQhIRyFAJYRuoH4PdMczHYtFCb-k94r-7TxEU0vp61ww4WntbPvVWwUbCUgsEtmDzAZT-NEJVhWztNk1ip9wDPXzZ2hEhDAPJ7A"; + }); + + describe("decode", () => { + + it("should decode a jwt", () => { + // act + const result = JwtUtils.decode(jwt); + + // assert + expect(result).toEqual({ + "iss": "https://localhost:44333/core", + "aud": "js.tokenmanager", + "exp": 1459130201, + "nbf": 1459129901, + "nonce": "7221005209972382", + "iat": 1459129901, + "at_hash": "JgDUCyoatJyEmGiiWbwOhA", + "sid": "0c5f061e6398b1ec6a0bce2c941ee4c5", + "sub": "88421113", + "auth_time": 1459129898, + "idp": "idsrv", + "amr": [ + "password" + ] + }); + }); + + it("should return undefined for an invalid jwt", () => { + // act + try { + JwtUtils.decode("junk"); + fail("should not come here"); + } + catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("Invalid token specified"); + } + }); + }); +});