From 3b034f93d6a794600d0aefc9607b00ebd17497ab Mon Sep 17 00:00:00 2001 From: pamapa Date: Thu, 20 Jan 2022 09:58:59 +0100 Subject: [PATCH] feat: #251 retry silent renew for fetch timeout --- docs/oidc-client-ts.api.md | 5 ++++- src/JsonService.test.ts | 16 ++++++++++++---- src/JsonService.ts | 19 ++++++++++++++++--- src/MetadataService.ts | 4 +++- src/OidcClientSettings.ts | 7 ++++++- src/ResponseValidator.ts | 2 +- src/TokenClient.ts | 6 ++++-- src/UserInfoService.test.ts | 2 +- src/UserInfoService.ts | 8 ++++++-- 9 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md index 25af312a9..50758a212 100644 --- a/docs/oidc-client-ts.api.md +++ b/docs/oidc-client-ts.api.md @@ -339,6 +339,7 @@ export interface OidcClientSettings { post_logout_redirect_uri?: string; prompt?: string; redirect_uri: string; + requestTimeoutInSeconds?: number; resource?: string; response_mode?: "query" | "fragment"; response_type?: string; @@ -353,7 +354,7 @@ export interface OidcClientSettings { // @public export class OidcClientSettingsStore { - constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, staleStateAgeInSeconds, clockSkewInSeconds, userInfoJwtIssuer, mergeClaims, stateStore, extraQueryParams, extraTokenParams, }: OidcClientSettings); + constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, staleStateAgeInSeconds, clockSkewInSeconds, requestTimeoutInSeconds, userInfoJwtIssuer, mergeClaims, stateStore, extraQueryParams, extraTokenParams, }: OidcClientSettings); // (undocumented) readonly acr_values: string | undefined; // (undocumented) @@ -393,6 +394,8 @@ export class OidcClientSettingsStore { // (undocumented) readonly redirect_uri: string; // (undocumented) + readonly requestTimeoutInSeconds: number; + // (undocumented) readonly resource: string | undefined; // (undocumented) readonly response_mode: "query" | "fragment"; diff --git a/src/JsonService.test.ts b/src/JsonService.test.ts index 5c65f9d0e..c5a58d3f0 100644 --- a/src/JsonService.test.ts +++ b/src/JsonService.test.ts @@ -2,14 +2,22 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. import { ErrorResponse } from "./errors"; +import { OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings"; import { JsonService } from "./JsonService"; + import { mocked } from "jest-mock"; describe("JsonService", () => { + let settings: OidcClientSettings; let subject: JsonService; beforeEach(() =>{ - subject = new JsonService(); + settings = { + authority: "authority", + client_id: "client", + redirect_uri: "redirect", + }; + subject = new JsonService(new OidcClientSettingsStore(settings)); }); describe("getJson", () => { @@ -118,7 +126,7 @@ describe("JsonService", () => { it("should accept custom content type in response", async () => { // arrange - subject = new JsonService(["foo/bar"]); + subject = new JsonService(new OidcClientSettingsStore(settings), ["foo/bar"]); const json = { foo: 1, bar: "test" }; mocked(fetch).mockResolvedValue({ status: 200, @@ -139,7 +147,7 @@ describe("JsonService", () => { it("should work with custom jwtHandler", async () => { // arrange const jwtHandler = jest.fn(); - subject = new JsonService([], jwtHandler); + subject = new JsonService(new OidcClientSettingsStore(settings), [], jwtHandler); const text = "text"; mocked(fetch).mockResolvedValue({ status: 200, @@ -381,7 +389,7 @@ describe("JsonService", () => { it("should accept custom content type in response", async () => { // arrange - subject = new JsonService(["foo/bar"]); + subject = new JsonService(new OidcClientSettingsStore(settings), ["foo/bar"]); const json = { foo: 1, bar: "test" }; mocked(fetch).mockResolvedValue({ status: 200, diff --git a/src/JsonService.ts b/src/JsonService.ts index 88fc7158c..813231b29 100644 --- a/src/JsonService.ts +++ b/src/JsonService.ts @@ -1,7 +1,8 @@ // 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 { ErrorResponse } from "./errors"; +import { ErrorResponse, ErrorTimeout } from "./errors"; +import type { OidcClientSettingsStore } from "./OidcClientSettings"; import { Logger } from "./utils"; /** @@ -18,6 +19,7 @@ export class JsonService { private _contentTypes: string[] = []; public constructor( + private readonly _settings: OidcClientSettingsStore, additionalContentTypes: string[] = [], private _jwtHandler: JwtHandler | null = null, ) { @@ -27,6 +29,17 @@ export class JsonService { } } + protected async fetchWithTimeout(input: RequestInfo, init?: RequestInit) { + return await Promise.race([ + fetch(input, init), + new Promise((_, reject) => { + setTimeout(() => { + reject(new ErrorTimeout("Network timed out")); + }, this._settings.requestTimeoutInSeconds * 1000); + }), + ]); + } + public async getJson(url: string, token?: string): Promise> { const logger = this._logger.create("getJson"); const headers: HeadersInit = { @@ -40,7 +53,7 @@ export class JsonService { let response: Response; try { logger.debug("url:", url); - response = await fetch(url, { method: "GET", headers }); + response = await this.fetchWithTimeout(url, { method: "GET", headers }); } catch (err) { logger.error("Network Error"); @@ -87,7 +100,7 @@ export class JsonService { let response: Response; try { logger.debug("url:", url); - response = await fetch(url, { method: "POST", headers, body }); + response = await this.fetchWithTimeout(url, { method: "POST", headers, body }); } catch (err) { logger.error("Network error"); diff --git a/src/MetadataService.ts b/src/MetadataService.ts index 76cafc62b..6be4b6e7b 100644 --- a/src/MetadataService.ts +++ b/src/MetadataService.ts @@ -13,7 +13,7 @@ const OidcMetadataUrlPath = ".well-known/openid-configuration"; */ export class MetadataService { private readonly _logger = new Logger("MetadataService"); - private readonly _jsonService = new JsonService(["application/jwk-set+json"]); + private readonly _jsonService: JsonService; // cache private _metadataUrl: string | null = null; @@ -21,6 +21,8 @@ export class MetadataService { private _metadata: Partial | null = null; public constructor(private readonly _settings: OidcClientSettingsStore) { + this._jsonService = new JsonService(_settings, ["application/jwk-set+json"]); + if (this._settings.metadataUrl) { this._metadataUrl = this._settings.metadataUrl; } else if (this._settings.authority) { diff --git a/src/OidcClientSettings.ts b/src/OidcClientSettings.ts index f33f072bf..48c4cc251 100644 --- a/src/OidcClientSettings.ts +++ b/src/OidcClientSettings.ts @@ -12,6 +12,7 @@ const DefaultClientAuthentication = "client_secret_post"; const DefaultResponseMode = "query"; const DefaultStaleStateAgeInSeconds = 60 * 15; const DefaultClockSkewInSeconds = 60 * 5; +const DefaultRequestTimeoutInSeconds = 8; /** * @public @@ -79,8 +80,9 @@ export interface OidcClientSettings { 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) */ clockSkewInSeconds?: number; + /** Number of seconds to wait for a network request to return before assuming it has timed out (default: 8) */ + requestTimeoutInSeconds?: number; userInfoJwtIssuer?: "ANY" | "OP" | string; - /** * Indicates if objects returned from the user info endpoint as claims (e.g. `address`) are merged into the claims from the id token as a single object. * Otherwise, they are added to an array as distinct objects for the claim type. (default: false) @@ -139,6 +141,7 @@ export class OidcClientSettingsStore { public readonly loadUserInfo: boolean; public readonly staleStateAgeInSeconds: number; public readonly clockSkewInSeconds: number; + public readonly requestTimeoutInSeconds: number; public readonly userInfoJwtIssuer: "ANY" | "OP" | string; public readonly mergeClaims: boolean; @@ -162,6 +165,7 @@ export class OidcClientSettingsStore { loadUserInfo = false, staleStateAgeInSeconds = DefaultStaleStateAgeInSeconds, clockSkewInSeconds = DefaultClockSkewInSeconds, + requestTimeoutInSeconds = DefaultRequestTimeoutInSeconds, userInfoJwtIssuer = "OP", mergeClaims = false, // other behavior @@ -197,6 +201,7 @@ export class OidcClientSettingsStore { this.loadUserInfo = !!loadUserInfo; this.staleStateAgeInSeconds = staleStateAgeInSeconds; this.clockSkewInSeconds = clockSkewInSeconds; + this.requestTimeoutInSeconds = requestTimeoutInSeconds; this.userInfoJwtIssuer = userInfoJwtIssuer; this.mergeClaims = !!mergeClaims; diff --git a/src/ResponseValidator.ts b/src/ResponseValidator.ts index efc889b03..6d9f482cd 100644 --- a/src/ResponseValidator.ts +++ b/src/ResponseValidator.ts @@ -45,7 +45,7 @@ const ProtocolClaims = [ */ export class ResponseValidator { protected readonly _logger = new Logger("ResponseValidator"); - protected readonly _userInfoService = new UserInfoService(this._metadataService); + protected readonly _userInfoService = new UserInfoService(this._settings, this._metadataService); protected readonly _tokenClient = new TokenClient(this._settings, this._metadataService); public constructor( diff --git a/src/TokenClient.ts b/src/TokenClient.ts index 9312612e1..47e393625 100644 --- a/src/TokenClient.ts +++ b/src/TokenClient.ts @@ -43,12 +43,14 @@ export interface RevokeArgs { */ export class TokenClient { private readonly _logger = new Logger("TokenClient"); - private readonly _jsonService = new JsonService(); + private readonly _jsonService: JsonService; public constructor( private readonly _settings: OidcClientSettingsStore, private readonly _metadataService: MetadataService, - ) {} + ) { + this._jsonService = new JsonService(_settings); + } public async exchangeCode({ grant_type = "authorization_code", diff --git a/src/UserInfoService.test.ts b/src/UserInfoService.test.ts index 6a74953b7..1ae8d5c1e 100644 --- a/src/UserInfoService.test.ts +++ b/src/UserInfoService.test.ts @@ -19,7 +19,7 @@ describe("UserInfoService", () => { }); metadataService = new MetadataService(settings); - subject = new UserInfoService(metadataService); + subject = new UserInfoService(settings, metadataService); // access private members jsonService = subject["_jsonService"]; diff --git a/src/UserInfoService.ts b/src/UserInfoService.ts index c921ccef5..ddba39999 100644 --- a/src/UserInfoService.ts +++ b/src/UserInfoService.ts @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. import { Logger, JwtUtils } from "./utils"; +import type { OidcClientSettingsStore } from "./OidcClientSettings"; import { JsonService } from "./JsonService"; import type { MetadataService } from "./MetadataService"; import type { JwtClaims } from "./Claims"; @@ -13,8 +14,11 @@ export class UserInfoService { protected readonly _logger = new Logger("UserInfoService"); private readonly _jsonService: JsonService; - public constructor(private readonly _metadataService: MetadataService) { - this._jsonService = new JsonService(undefined, this._getClaimsFromJwt); + public constructor( + settings: OidcClientSettingsStore, + private readonly _metadataService: MetadataService, + ) { + this._jsonService = new JsonService(settings, undefined, this._getClaimsFromJwt); } public async getClaims(token: string): Promise {