Skip to content

Commit

Permalink
Renew access & refresh token on expiration #63
Browse files Browse the repository at this point in the history
  • Loading branch information
hupf committed Nov 30, 2023
1 parent 151fafd commit 8ac088f
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 7 deletions.
32 changes: 25 additions & 7 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import {
storeToken,
} from "./storage";
import { getTokenPayload, isTokenExpired, isValidToken } from "./token";
import {
clearTokenRenewalTimers,
renewAccessTokenOnExpiration,
renewRefreshTokenOnExpiration,
renewTokenOnExpiration,
} from "./token-renewal";

const envSettings = window.eventoPortal.settings;

Expand Down Expand Up @@ -66,15 +72,15 @@ export async function ensureAuthenticated(
if (loginResult) {
// Successfully logged in
console.log("Successfully logged in");
handleLoginResult(loginResult, loginState);
handleLoginResult(client, loginResult, loginState);
return;
}

const substitutionResult = getTokenAfterSubstitutionRedirect();
if (substitutionResult) {
// Started or stopped substitution
console.log("Successfully started or stopped substitution");
handleSubstitutionResult(substitutionResult);
handleSubstitutionResult(client, substitutionResult);
return;
}

Expand Down Expand Up @@ -102,12 +108,15 @@ export async function activateTokenForScope(
): Promise<void> {
console.log(`Activate token for scope "${scope}" and locale "${locale}"`);

if (isTokenExpired(getRefreshToken())) {
const refreshToken = getRefreshToken();
if (!refreshToken || isTokenExpired(refreshToken)) {
// Not authenticated or refresh token expired, redirect to login
console.log(
"Not authenticated or refresh token expired, redirect to login",
);
return redirect(client, scope, locale, loginUrl);
} else {
renewRefreshTokenOnExpiration(client, refreshToken);
}

const currentAccessToken = getCurrentAccessToken();
Expand All @@ -118,12 +127,14 @@ export async function activateTokenForScope(
console.log(
`Current token for scope "${scope}" and locale "${locale}" already set`,
);
renewAccessTokenOnExpiration(client, currentAccessToken);
} else if (isValidToken(cachedAccessToken, scope, locale)) {
// Token for scope/locale cached, set as current
console.log(
`Token for scope "${scope}" and locale "${locale}" cached, set as current`,
);
storeCurrentAccessToken(cachedAccessToken);
renewAccessTokenOnExpiration(client, cachedAccessToken);
} else {
// No token for scope/locale present or half expired, redirect for
// token fetch/refresh
Expand Down Expand Up @@ -157,6 +168,7 @@ export async function logout(client: OAuth2Client): Promise<void> {
}
} finally {
resetAllTokens();
clearTokenRenewalTimers();

// Redirect to login with scope/locale of current token
const { scope, locale } = getTokenPayload(token);
Expand All @@ -179,7 +191,7 @@ type RedirectUrlBuilder = (
codeVerifier: string,
) => Promise<URL>;

async function redirect(
export async function redirect(
client: OAuth2Client,
scope: string,
locale: string,
Expand All @@ -200,7 +212,7 @@ async function redirect(
document.location.href = url.toString();
}

const loginUrl: RedirectUrlBuilder = async (
export const loginUrl: RedirectUrlBuilder = async (
client,
scope,
locale,
Expand All @@ -224,7 +236,7 @@ const loginUrl: RedirectUrlBuilder = async (
return url;
};

const refreshUrl: RedirectUrlBuilder = async (
export const refreshUrl: RedirectUrlBuilder = async (
client,
scope,
locale,
Expand Down Expand Up @@ -273,6 +285,7 @@ async function getTokenAfterLogin(
}

function handleLoginResult(
client: OAuth2Client,
token: OAuth2Token,
loginState: {
codeVerifier: string;
Expand All @@ -283,6 +296,7 @@ function handleLoginResult(
const { scope, instanceId } = getTokenPayload(accessToken);
storeToken(scope, token);
storeCurrentAccessToken(accessToken);
renewTokenOnExpiration(client, token);

// Remember the chosen instance for later logins
storeInstance(instanceId);
Expand Down Expand Up @@ -317,11 +331,15 @@ function getTokenAfterSubstitutionRedirect(): OAuth2Token | null {
return null;
}

function handleSubstitutionResult(token: OAuth2Token): void {
function handleSubstitutionResult(
client: OAuth2Client,
token: OAuth2Token,
): void {
const { accessToken } = token;
const { scope } = getTokenPayload(accessToken);
storeToken(scope, token);
storeCurrentAccessToken(accessToken);
renewTokenOnExpiration(client, token);

// Remove sensitive information from URL
const url = new URL(document.location.href);
Expand Down
83 changes: 83 additions & 0 deletions src/utils/token-renewal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { OAuth2Client, OAuth2Token } from "@badgateway/oauth2-client";
import { loginUrl, redirect, refreshUrl } from "./auth";
import { getCurrentAccessToken } from "./storage";
import { getTokenExpireIn, getTokenPayload } from "./token";

enum TokenType {
Refresh = "refresh",
Access = "access",
}

const expirationTimers: Record<
TokenType,
ReturnType<typeof setTimeout> | undefined
> = {
refresh: undefined,
access: undefined,
};

export function renewTokenOnExpiration(
client: OAuth2Client,
token: OAuth2Token,
): void {
const { refreshToken, accessToken } = token;
if (refreshToken) {
renewRefreshTokenOnExpiration(client, refreshToken);
}
renewAccessTokenOnExpiration(client, accessToken);
}

export function renewRefreshTokenOnExpiration(
client: OAuth2Client,
refreshToken: string,
): void {
onExpiration(TokenType.Refresh, refreshToken, () => {
// Get the scope of the "current" access token at the time the
// refresh token expires, since the user may have switched scopes
// in the meantime
const accessToken = getCurrentAccessToken();
if (!accessToken) {
return;
}

console.log(`Refresh token expired, redirect to login`);
const { scope, locale } = getTokenPayload(accessToken);
redirect(client, scope, locale, loginUrl);
});
}

export function renewAccessTokenOnExpiration(
client: OAuth2Client,
accessToken: string,
): void {
const { scope, locale } = getTokenPayload(accessToken);
onExpiration(TokenType.Access, accessToken, () => {
console.log(
`Access token for scope "${scope}" and locale "${locale}" expired, redirect for token fetch/refresh`,
);
redirect(client, scope, locale, refreshUrl);
});
}

export function clearTokenRenewalTimers(): void {
Object.values(TokenType).forEach((type) => {
if (expirationTimers[type]) {
clearTimeout(expirationTimers[type]);
}
});
}

/**
* Calls the given callback when the given token expires, canceling
* ongoing timers for the given token type (if there are any).
*/
function onExpiration(
type: TokenType,
token: string,
callback: () => void,
): void {
if (expirationTimers[type]) {
clearTimeout(expirationTimers[type]);
}
expirationTimers[type] = setTimeout(callback, getTokenExpireIn(token));
}
9 changes: 9 additions & 0 deletions src/utils/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ export function isTokenHalfExpired(
return expirationTime <= now + validFor / 2;
}

/**
* Returns the time (in milliseconds) the token will expire from now (0
* if already expired).
*/
export function getTokenExpireIn(token: string): number {
const { expirationTime } = getTokenPayload(token);
return Math.max(expirationTime * 1000 - Date.now(), 0);
}

/**
* Returns whether the given token matches the given scope.
*/
Expand Down

0 comments on commit 8ac088f

Please sign in to comment.