diff --git a/src/apim.design.module.ts b/src/apim.design.module.ts index b6f77e235..ed540c5b7 100644 --- a/src/apim.design.module.ts +++ b/src/apim.design.module.ts @@ -58,6 +58,7 @@ import { PolicyService } from "./services/policyService"; import { OAuthService } from "./services/oauthService"; import { HistoryRouteHandler } from "@paperbits/common/routing"; import { OldContentRouteGuard } from "./routing/oldContentRouteGuard"; +import { AccessTokenRefrsher } from "./authentication/accessTokenRefresher"; export class ApimDesignModule implements IInjectorModule { @@ -124,5 +125,6 @@ export class ApimDesignModule implements IInjectorModule { injector.bindInstance("reservedPermalinks", Constants.reservedPermalinks); injector.bindSingleton("oauthService", OAuthService); injector.bindToCollection("autostart", HistoryRouteHandler); + injector.bindToCollection("autostart", AccessTokenRefrsher); } } \ No newline at end of file diff --git a/src/authentication/accessToken.ts b/src/authentication/accessToken.ts index 87eadfcb9..f6a290606 100644 --- a/src/authentication/accessToken.ts +++ b/src/authentication/accessToken.ts @@ -1,4 +1,3 @@ -import * as moment from "moment"; import { Utils } from "../utils"; export class AccessToken { @@ -46,8 +45,13 @@ export class AccessToken { } const dateTime = match[1]; - const dateTimeIso = `${dateTime.substr(0, 8)} ${dateTime.substr(8, 4)}`; - const expirationDateUtc = moment(dateTimeIso).toDate(); + const year = dateTime.substr(0, 4); + const month = dateTime.substr(4, 2); + const day = dateTime.substr(6, 2); + const hour = dateTime.substr(8, 2); + const minute = dateTime.substr(10, 2); + const dateTimeIso = `${year}-${month}-${day}T${hour}:${minute}:00.000Z`; + const expirationDateUtc = new Date(dateTimeIso); return new AccessToken("SharedAccessSignature", value, expirationDateUtc); } @@ -91,9 +95,12 @@ export class AccessToken { } public isExpired(): boolean { - const utcNow = Utils.getUtcDateTime(); + const now = new Date(); + return now > this.expires; + } - return utcNow > this.expires; + public expiresInMs(): number { + return this.expires.getTime() - (new Date()).getTime(); } public toString(): string { diff --git a/src/authentication/accessTokenRefresher.ts b/src/authentication/accessTokenRefresher.ts new file mode 100644 index 000000000..20acd971e --- /dev/null +++ b/src/authentication/accessTokenRefresher.ts @@ -0,0 +1,59 @@ +import * as Constants from "./../constants"; +import { ISettingsProvider } from "@paperbits/common/configuration"; +import { EventManager } from "@paperbits/common/events"; +import { HttpClient } from "@paperbits/common/http"; +import { Logger } from "@paperbits/common/logging"; +import { KnownHttpHeaders } from "../models/knownHttpHeaders"; +import { Utils } from "../utils"; +import { AccessToken, IAuthenticator } from "./../authentication"; + + +export class AccessTokenRefrsher { + constructor( + eventManager: EventManager, + private readonly settingsProvider: ISettingsProvider, + private readonly authenticator: IAuthenticator, + private readonly httpClient: HttpClient, + private readonly logger: Logger + ) { + eventManager.addEventListener("authenticated", this.refreshToken.bind(this)); + } + + private async refreshToken(): Promise { + const settings = await this.settingsProvider.getSettings(); + + let managementApiUrl = settings[Constants.SettingNames.managementApiUrl]; + + if (!managementApiUrl) { + throw new Error(`Management API URL ("${Constants.SettingNames.managementApiUrl}") setting is missing in configuration file.`); + } + + managementApiUrl = Utils.ensureUrlArmified(managementApiUrl); + + try { + const accessToken = await this.authenticator.getAccessToken(); + + if (!accessToken) { + return; + } + + const response = await this.httpClient.send({ + method: "GET", + url: `${managementApiUrl}${Utils.ensureLeadingSlash("/identity")}?api-version=${Constants.managementApiVersion}`, + headers: [{ name: "Authorization", value: accessToken }] + }); + + const accessTokenHeader = response.headers.find(x => x.name.toLowerCase() === KnownHttpHeaders.OcpApimSasToken.toLowerCase()); + + if (!accessTokenHeader) { + return; + } + + const newAccessToken = AccessToken.parse(accessTokenHeader.value); + await this.authenticator.setAccessToken(newAccessToken); + } + catch (error) { + this.logger.trackError(error, { message: "Unable to refresh access token." }); + } + } +} \ No newline at end of file diff --git a/src/components/app/app.ts b/src/components/app/app.ts index 245d99ff2..e55f075e5 100644 --- a/src/components/app/app.ts +++ b/src/components/app/app.ts @@ -42,12 +42,11 @@ export class App { } const accessToken = AccessToken.parse(managementApiAccessToken); - const utcNow = Utils.getUtcDateTime(); + const now = new Date(); - if (utcNow >= accessToken.expires) { + if (now >= accessToken.expires) { this.viewManager.addToast(startupError, `Management API access token has expired. See setting managementApiAccessToken in the configuration file config.design.json`); this.authenticator.clearAccessToken(); - window.location.assign("/signout"); return; } diff --git a/src/components/defaultAuthenticator.ts b/src/components/defaultAuthenticator.ts index faafb2ce3..cee64331a 100644 --- a/src/components/defaultAuthenticator.ts +++ b/src/components/defaultAuthenticator.ts @@ -1,16 +1,19 @@ +import { EventManager } from "@paperbits/common/events"; import { IAuthenticator, AccessToken } from "./../authentication"; export class DefaultAuthenticator implements IAuthenticator { + constructor(private readonly eventManager: EventManager) { } + public async getAccessToken(): Promise { const accessToken = sessionStorage.getItem("accessToken"); - + if (!accessToken && window.location.pathname.startsWith("/signin-sso")) { const url = new URL(location.href); const queryParams = new URLSearchParams(url.search); const tokenValue = queryParams.get("token"); const token = AccessToken.parse(`SharedAccessSignature ${tokenValue}`); await this.setAccessToken(token); - + const returnUrl = queryParams.get("returnUrl") || "/"; window.location.assign(returnUrl); } @@ -24,6 +27,19 @@ export class DefaultAuthenticator implements IAuthenticator { } sessionStorage.setItem("accessToken", accessToken.toString()); + + const expiresInMs = accessToken.expiresInMs(); + const refreshBufferMs = 5 * 60 * 1000; // 5 min + const nextRefreshInMs = expiresInMs - refreshBufferMs; + + if (expiresInMs < refreshBufferMs) { + // Refresh immediately + this.eventManager.dispatchEvent("authenticated"); + } + else { + // Schedule refresh 5 min before expiration. + setTimeout(() => this.eventManager.dispatchEvent("authenticated"), nextRefreshInMs); + } } public clearAccessToken(): void { diff --git a/src/components/operations/operation-details/ko/runtime/operation-console.ts b/src/components/operations/operation-details/ko/runtime/operation-console.ts index 4ad404aef..1dffd25e9 100644 --- a/src/components/operations/operation-details/ko/runtime/operation-console.ts +++ b/src/components/operations/operation-details/ko/runtime/operation-console.ts @@ -542,7 +542,7 @@ export class OperationConsole { try { /* Trying to check if it's a JWT token and, if yes, whether it got expired. */ const jwtToken = Utils.parseJwt(storedCredentials.accessToken.replace(/^bearer /i, "")); - const now = Utils.getUtcDateTime(); + const now = new Date(); if (now > jwtToken.exp) { await this.clearStoredCredentials(); diff --git a/src/contracts/jwtToken.ts b/src/contracts/jwtToken.ts index 1c1314531..3235da6bb 100644 --- a/src/contracts/jwtToken.ts +++ b/src/contracts/jwtToken.ts @@ -20,7 +20,7 @@ export interface JwtToken { aud: string; /** - * Expiration time (UTC) + * Expiration time. */ exp: Date; @@ -35,7 +35,7 @@ export interface JwtToken { given_name: string; /** - * Issued at (UTC) + * Issued at. */ iat: Date; @@ -50,7 +50,7 @@ export interface JwtToken { iss: string; /** - * Not valid before (UTC). + * Not valid before. */ nbf: Date; diff --git a/src/utils.ts b/src/utils.ts index ac7c5384d..da83339ce 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -200,15 +200,20 @@ export class Utils { let suffix = " ms"; let divider = 1; - if (milliseconds > 1000) { + if (milliseconds >= 1000) { suffix = " s"; divider = 1000; } - - if (milliseconds > 1000 * 60) { - suffix = " h"; + + if (milliseconds >= 1000 * 60) { + suffix = " m"; divider = 1000 * 60; } + + if (milliseconds >= 1000 * 60 * 60) { + suffix = " h"; + divider = 1000 * 60 * 60; + } return `${(milliseconds / divider).toFixed(0)}${suffix}`; } @@ -271,21 +276,18 @@ export class Utils { public static parseJwt(jwtToken: string): JwtToken { const base64Url = jwtToken.split(".")[1]; const base64 = base64Url.replace("-", "+").replace("_", "/"); - const decodedToken = JSON.parse(window.atob(base64)); - - const now = new Date(); - const offset = now.getTimezoneOffset() * 60000 * 1000; + const decodedToken = JSON.parse(Buffer.from(base64, "base64").toString()); if (decodedToken.exp) { - decodedToken.exp = new Date(decodedToken.exp + offset); + decodedToken.exp = new Date(parseInt(decodedToken.exp) * 1000); } if (decodedToken.nfb) { - decodedToken.nfb = new Date(decodedToken.nfb + offset); + decodedToken.nfb = new Date(parseInt(decodedToken.nfb) * 1000); } if (decodedToken.iat) { - decodedToken.iat = new Date(decodedToken.iat + offset); + decodedToken.iat = new Date(parseInt(decodedToken.iat) * 1000); } return decodedToken; @@ -349,13 +351,6 @@ export class Utils { return JSON.parse(JSON.stringify(obj)); } - public static getUtcDateTime(): Date { - const now = new Date(); - const utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000); - - return utc; - } - public static readFileAsByteArray(file: File): Promise { return new Promise(resolve => { const reader = new FileReader();