browsertrix/frontend/src/utils/AuthService.ts
2021-12-03 10:00:13 -08:00

202 lines
4.7 KiB
TypeScript

import { APIError } from "./api";
export type Auth = {
username: string;
headers: {
Authorization: string;
};
tokenExpiresAt: number;
};
type Session = {
sessionExpiresAt: number;
};
export type AuthState = (Auth & Session) | null;
type LoggedInEventDetail = Auth & {
api?: boolean;
firstLogin?: boolean;
};
export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {
readonly detail: T;
}
// Check for token freshness every 5 minutes
const FRESHNESS_TIMER_INTERVAL = 60 * 1000 * 5;
// TODO get expires at from server
// Hardcode 1hr expiry for now
const ACCESS_TOKEN_LIFETIME = 1000 * 60 * 60;
// Hardcode 24h expiry for now
const SESSION_LIFETIME = 1000 * 60 * 60 * 24;
export default class AuthService {
private timerId?: number;
private _authState: AuthState = null;
static storageKey = "btrix.auth";
static unsupportedAuthErrorCode = "UNSUPPORTED_AUTH_TYPE";
static loggedInEvent = "logged-in";
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 authHeaders = AuthService.parseAuthHeaders(await resp.json());
return {
username: email,
headers: authHeaders,
// TODO get expires at from server
// Hardcode 1hr expiry for now
tokenExpiresAt: Date.now() + ACCESS_TOKEN_LIFETIME,
};
}
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(): AuthState {
const auth = window.localStorage.getItem(AuthService.storageKey);
if (auth) {
this._authState = JSON.parse(auth);
this.checkFreshness();
}
return this._authState;
}
startPersist(auth: Auth) {
if (auth) {
this.persist(auth);
this.checkFreshness();
} else {
console.warn("No authState to persist");
}
}
logout() {
window.clearTimeout(this.timerId);
this.revoke();
}
private revoke() {
this._authState = null;
window.localStorage.removeItem(AuthService.storageKey);
}
private persist(auth: Auth) {
this._authState = {
username: auth.username,
headers: auth.headers,
tokenExpiresAt: auth.tokenExpiresAt,
sessionExpiresAt: Date.now() + SESSION_LIFETIME,
};
window.localStorage.setItem(AuthService.storageKey, JSON.stringify(auth));
}
private async checkFreshness() {
window.clearTimeout(this.timerId);
if (!this._authState) return;
const paddedNow = Date.now() + FRESHNESS_TIMER_INTERVAL;
if (this._authState.sessionExpiresAt > paddedNow) {
if (this._authState.tokenExpiresAt > 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;
// Restart timer
this.timerId = window.setTimeout(() => {
this.checkFreshness();
}, FRESHNESS_TIMER_INTERVAL);
} catch (e) {
console.debug(e);
}
}
} else {
this.logout();
}
}
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 authHeaders = AuthService.parseAuthHeaders(await resp.json());
return {
headers: authHeaders,
// TODO get expires at from server
// Hardcode 1hr expiry for now
tokenExpiresAt: Date.now() + ACCESS_TOKEN_LIFETIME,
};
}
}