* render even if session store throws * handle after timeout * remove localstorage key * update tests
344 lines
8.7 KiB
TypeScript
344 lines
8.7 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 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(),
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
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> {
|
|
const broadcastPromise = new Promise((resolve) => {
|
|
// Check if there's any authenticated tabs
|
|
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);
|
|
});
|
|
// Ensure that `getSharedSessionAuth` is resolved within a reasonable
|
|
// timeframe, even if another window/tab doesn't respond:
|
|
const timeoutPromise = new Promise((resolve) => {
|
|
window.setTimeout(() => {
|
|
resolve(null);
|
|
}, 10);
|
|
});
|
|
|
|
return Promise.race([broadcastPromise, timeoutPromise]).then(
|
|
(value: any) => {
|
|
try {
|
|
if (value.username && value.headers && value.tokenExpiresAt) {
|
|
return value;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
(error) => {
|
|
console.debug(error);
|
|
return 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) {
|
|
this.persist(auth);
|
|
this.startFreshnessCheck();
|
|
}
|
|
|
|
logout() {
|
|
this.cancelFreshnessCheck();
|
|
this.revoke();
|
|
}
|
|
|
|
startFreshnessCheck() {
|
|
window.clearTimeout(this.timerId);
|
|
this.checkFreshness();
|
|
}
|
|
|
|
private cancelFreshnessCheck() {
|
|
window.clearTimeout(this.timerId);
|
|
this.timerId = undefined;
|
|
}
|
|
|
|
private revoke() {
|
|
this._authState = null;
|
|
AuthService.storage.removeItem();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|