browsertrix/frontend/src/utils/AuthService.ts
sua yoo e7f1a00411
Fix authentication getting out of sync between tabs (#380)
Fixes regression to #361 found after increasing the token timeout by preventing app load until the authentication service is initialized (and finishing check if another tab is logged in.)
2022-11-23 23:36:36 -08:00

340 lines
8.6 KiB
TypeScript

import { APIError } from "./api";
export type Auth = {
username: string;
headers: {
Authorization: string;
};
/** Timestamp (milliseconds) when token expires */
tokenExpiresAt: number;
};
export type AuthState = Auth | null;
type JWT = {
user_id: string;
aud: string[];
exp: number;
};
export type LoggedInEventDetail = Auth & {
api?: boolean;
firstLogin?: boolean;
redirectUrl?: string;
};
export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {
readonly detail: T;
}
type HasAuthStorageData = {
auth: boolean;
};
type AuthRequestEventData = {
name: "requesting_auth";
};
type AuthResponseEventData = {
name: "responding_auth";
auth: AuthState;
};
export type AuthStorageEventData = {
name: "auth_storage";
value: string | null;
};
// Check for token freshness every 5 minutes
const FRESHNESS_TIMER_INTERVAL = 60 * 1000 * 5;
export default class AuthService {
private timerId?: number;
private _authState: AuthState = null;
static storageKey = "btrix.auth";
static unsupportedAuthErrorCode = "UNSUPPORTED_AUTH_TYPE";
static loggedInEvent = "logged-in";
static broadcastChannel = new BroadcastChannel(AuthService.storageKey);
static storage = {
getItem() {
return window.sessionStorage.getItem(AuthService.storageKey);
},
setItem(newValue: string) {
const oldValue = AuthService.storage.getItem();
if (oldValue === newValue) return;
window.sessionStorage.setItem(AuthService.storageKey, newValue);
AuthService.broadcastChannel.postMessage(<AuthStorageEventData>{
name: "auth_storage",
value: newValue,
});
},
removeItem() {
const oldValue = AuthService.storage.getItem();
if (!oldValue) return;
window.sessionStorage.removeItem(AuthService.storageKey);
AuthService.broadcastChannel.postMessage(<AuthStorageEventData>{
name: "auth_storage",
value: null,
});
},
};
get authState() {
return this._authState;
}
static createLoggedInEvent = (detail: LoggedInEventDetail): LoggedInEvent => {
return new CustomEvent(AuthService.loggedInEvent, { detail });
};
static async login({
email,
password,
}: {
email: string;
password: string;
}): Promise<Auth> {
const resp = await fetch("/api/auth/jwt/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "password",
username: email,
password: password,
}).toString(),
});
if (resp.status !== 200) {
throw new APIError({
message: resp.statusText,
status: resp.status,
});
}
const data = await resp.json();
const token = AuthService.decodeToken(data.access_token);
const authHeaders = AuthService.parseAuthHeaders(data);
return {
username: email,
headers: authHeaders,
tokenExpiresAt: token.exp * 1000,
};
}
/**
* Decode JSON web token returned as access token
*/
private static decodeToken(token: string): JWT {
return JSON.parse(window.atob(token.split(".")[1]));
}
/**
* Build authorization headers from login response
*/
private static parseAuthHeaders(data: {
token_type: string;
access_token: string;
}): Auth["headers"] {
if (data.token_type === "bearer" && data.access_token) {
return {
Authorization: `Bearer ${data.access_token}`,
};
}
throw new Error(AuthService.unsupportedAuthErrorCode);
}
/**
* Retrieve or set auth data from shared session
* and set up session syncing
*/
static async initSessionStorage(): Promise<AuthState> {
const authState =
AuthService.getCurrentTabAuth() ||
(await AuthService.getSharedSessionAuth());
AuthService.broadcastChannel.addEventListener(
"message",
({ data }: { data: AuthRequestEventData | AuthStorageEventData }) => {
if (data.name === "requesting_auth") {
// A new tab/window opened and is requesting shared auth
AuthService.broadcastChannel.postMessage(<AuthResponseEventData>{
name: "responding_auth",
auth: AuthService.getCurrentTabAuth(),
});
}
}
);
window.addEventListener("beforeunload", () => {
window.localStorage.removeItem(AuthService.storageKey);
});
return authState;
}
private static getCurrentTabAuth(): AuthState {
const auth = AuthService.storage.getItem();
if (auth) {
return JSON.parse(auth);
}
return null;
}
/**
* Retrieve shared session from another tab/window
**/
private static async getSharedSessionAuth(): Promise<AuthState> {
return new Promise((resolve) => {
// Check if there's any authenticated tabs
const value = window.localStorage.getItem(AuthService.storageKey);
if (value && (JSON.parse(value) as HasAuthStorageData).auth) {
// Ask for auth
AuthService.broadcastChannel.postMessage(<AuthRequestEventData>{
name: "requesting_auth",
});
// Wait for another tab to respond
const cb = ({ data }: any) => {
if (data.name === "responding_auth") {
AuthService.broadcastChannel.removeEventListener("message", cb);
resolve(data.auth);
}
};
AuthService.broadcastChannel.addEventListener("message", cb);
} else {
resolve(null);
}
});
}
constructor() {
// Only have freshness check run in visible tab(s)
document.addEventListener("visibilitychange", () => {
if (!this._authState) return;
if (document.visibilityState === "visible") {
this.startFreshnessCheck();
} else {
this.cancelFreshnessCheck();
}
});
}
saveLogin(auth: Auth) {
window.localStorage.setItem(
AuthService.storageKey,
JSON.stringify(<HasAuthStorageData>{ auth: true })
);
this.persist(auth);
this.startFreshnessCheck();
}
logout() {
window.localStorage.removeItem(AuthService.storageKey);
this.cancelFreshnessCheck();
this.revoke();
}
private startFreshnessCheck() {
window.clearTimeout(this.timerId);
this.checkFreshness();
}
private cancelFreshnessCheck() {
window.clearTimeout(this.timerId);
this.timerId = undefined;
}
private revoke() {
this._authState = null;
AuthService.storage.removeItem();
}
private persist(auth: Auth) {
this._authState = {
username: auth.username,
headers: auth.headers,
tokenExpiresAt: auth.tokenExpiresAt,
};
AuthService.storage.setItem(JSON.stringify(this._authState));
}
private async checkFreshness() {
// console.debug("checkFreshness authState:", this._authState);
if (!this._authState) return;
const paddedNow = Date.now() + FRESHNESS_TIMER_INTERVAL - 500; // tweak padding to account for API fetch time
if (this._authState.tokenExpiresAt > paddedNow) {
// console.debug(
// "fresh! restart timer tokenExpiresAt:",
// new Date(this._authState.tokenExpiresAt)
// );
// console.debug("fresh! restart timer paddedNow:", new Date(paddedNow));
// Restart timer
this.timerId = window.setTimeout(() => {
this.checkFreshness();
}, FRESHNESS_TIMER_INTERVAL);
} else {
try {
const auth = await this.refresh();
this._authState.headers = auth.headers;
this._authState.tokenExpiresAt = auth.tokenExpiresAt;
this.persist(this._authState);
// console.debug(
// "refreshed. restart timer tokenExpiresAt:",
// new Date(this._authState.tokenExpiresAt)
// );
// console.debug(
// "refreshed. restart timer paddedNow:",
// new Date(paddedNow)
// );
// Restart timer
this.timerId = window.setTimeout(() => {
this.checkFreshness();
}, FRESHNESS_TIMER_INTERVAL);
} catch (e) {
console.debug(e);
}
}
}
private async refresh(): Promise<{
headers: Auth["headers"];
tokenExpiresAt: Auth["tokenExpiresAt"];
}> {
if (!this.authState) {
throw new Error("No this.authState");
}
const resp = await fetch("/api/auth/jwt/refresh", {
method: "POST",
headers: this.authState.headers,
});
if (resp.status !== 200) {
throw new APIError({
message: resp.statusText,
status: resp.status,
});
}
const data = await resp.json();
const token = AuthService.decodeToken(data.access_token);
const authHeaders = AuthService.parseAuthHeaders(data);
return {
headers: authHeaders,
tokenExpiresAt: token.exp * 1000,
};
}
}