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.)
This commit is contained in:
sua yoo 2022-11-23 23:36:36 -08:00 committed by GitHub
parent da8260a028
commit e7f1a00411
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 147 additions and 103 deletions

View File

@ -14,7 +14,7 @@ import AuthService from "./utils/AuthService";
import type { LoggedInEvent } from "./utils/AuthService";
import type { ViewState } from "./utils/APIRouter";
import type { CurrentUser } from "./types/user";
import type { AuthState } from "./utils/AuthService";
import type { AuthStorageEventData } from "./utils/AuthService";
import theme from "./theme";
import { ROUTES, DASHBOARD_ROUTE } from "./routes";
import "./shoelace";
@ -64,45 +64,37 @@ export class App extends LiteElement {
@state()
private isRegistrationEnabled?: boolean;
constructor() {
super();
const authState = this.authService.retrieve();
async connectedCallback() {
const authState = await AuthService.initSessionStorage();
if (authState) {
if (
window.location.pathname === "/log-in" ||
window.location.pathname === "/reset-password"
) {
// Redirect to logged in home page
this.viewState = this.router.match(ROUTES.myAccount);
window.history.replaceState(
this.viewState,
"",
this.viewState.pathname
);
}
this.authService.saveLogin(authState);
}
this.syncViewState();
}
private syncViewState() {
this.viewState = this.router.match(
`${window.location.pathname}${window.location.search}`
);
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("popstate", (event) => {
window.addEventListener("popstate", () => {
this.syncViewState();
});
this.startSyncBrowserTabs();
}
private syncViewState() {
if (
this.authService.authState &&
(window.location.pathname === "/log-in" ||
window.location.pathname === "/reset-password")
) {
// Redirect to logged in home page
this.viewState = this.router.match(DASHBOARD_ROUTE);
window.history.replaceState(this.viewState, "", this.viewState.pathname);
} else {
this.viewState = this.router.match(
`${window.location.pathname}${window.location.search}`
);
}
}
async firstUpdated() {
if (this.authService.authState) {
this.updateUserInfo();
@ -605,7 +597,7 @@ export class App extends LiteElement {
onLoggedIn(event: LoggedInEvent) {
const { detail } = event;
this.authService.startPersist({
this.authService.saveLogin({
username: detail.username,
headers: detail.headers,
tokenExpiresAt: detail.tokenExpiresAt,
@ -728,52 +720,23 @@ export class App extends LiteElement {
}
private startSyncBrowserTabs() {
// TODO remove this line after this change has been deployed
// for more than 24 hours
window.localStorage.removeItem(AuthService.storageKey);
// Sync local auth state across window/tabs
// Notify any already open windows that new window is open
AuthService.broadcastChannel.postMessage({ name: "need_auth" });
AuthService.broadcastChannel.addEventListener("message", ({ data }) => {
if (data.name === "need_auth") {
// Share auth with newly opened tab
const auth = AuthService.storage.getItem();
if (auth) {
AuthService.broadcastChannel.postMessage({
name: "storage",
key: AuthService.storageKey,
oldValue: auth,
newValue: auth,
});
}
}
if (data.name === "storage") {
const { key, oldValue, newValue } = data;
if (key === AuthService.storageKey && newValue !== oldValue) {
if (oldValue && newValue === null) {
// Logged out from another tab
this.onLogOut(
new CustomEvent("log-out", { detail: { redirect: true } })
);
} else if (!oldValue && newValue) {
// Logged in from another tab
const auth = JSON.parse(newValue);
this.onLoggedIn(AuthService.createLoggedInEvent(auth));
AuthService.broadcastChannel.addEventListener(
"message",
({ data }: { data: AuthStorageEventData }) => {
if (data.name === "auth_storage") {
if (data.value !== AuthService.storage.getItem()) {
if (data.value) {
this.authService.saveLogin(JSON.parse(data.value));
this.updateUserInfo();
this.syncViewState();
} else {
this.authService.logout();
this.navigate(ROUTES.login);
}
}
}
}
});
// Only have freshness check run in visible tab(s)
document.addEventListener("visibilitychange", () => {
if (!this.authService.authState) return;
if (document.visibilityState === "visible") {
this.authService.startFreshnessCheck();
} else {
this.authService.cancelFreshnessCheck();
}
});
);
}
}

View File

@ -8,7 +8,6 @@ export const ROUTES = {
loginWithRedirect: "/log-in?redirectUrl",
forgotPassword: "/log-in/forgot-password",
resetPassword: "/reset-password?token",
myAccount: "/my-account",
accountSettings: "/account/settings",
archives: "/archives",
archive: "/archives/:id/:tab",

View File

@ -27,6 +27,24 @@ 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;
@ -45,22 +63,20 @@ export default class AuthService {
},
setItem(newValue: string) {
const oldValue = AuthService.storage.getItem();
if (oldValue === newValue) return;
window.sessionStorage.setItem(AuthService.storageKey, newValue);
AuthService.broadcastChannel.postMessage({
name: "storage",
key: AuthService.storageKey,
oldValue,
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({
name: "storage",
key: AuthService.storageKey,
oldValue,
newValue: null,
AuthService.broadcastChannel.postMessage(<AuthStorageEventData>{
name: "auth_storage",
value: null,
});
},
};
@ -133,41 +149,108 @@ export default class AuthService {
throw new Error(AuthService.unsupportedAuthErrorCode);
}
retrieve(): AuthState {
/**
* 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) {
this._authState = JSON.parse(auth);
this.checkFreshness();
return JSON.parse(auth);
}
return this._authState;
return null;
}
startPersist(auth: Auth) {
if (auth) {
this.persist(auth);
this.startFreshnessCheck();
} else {
console.warn("No authState to persist");
}
/**
* 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);
}
});
}
startFreshnessCheck() {
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();
}
cancelFreshnessCheck() {
private cancelFreshnessCheck() {
window.clearTimeout(this.timerId);
this.timerId = undefined;
}
logout() {
this.cancelFreshnessCheck();
this.revoke();
}
private revoke() {
this._authState = null;
AuthService.storage.removeItem();
@ -179,7 +262,6 @@ export default class AuthService {
headers: auth.headers,
tokenExpiresAt: auth.tokenExpiresAt,
};
AuthService.storage.setItem(JSON.stringify(this._authState));
}