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 { LoggedInEvent } from "./utils/AuthService";
import type { ViewState } from "./utils/APIRouter"; import type { ViewState } from "./utils/APIRouter";
import type { CurrentUser } from "./types/user"; import type { CurrentUser } from "./types/user";
import type { AuthState } from "./utils/AuthService"; import type { AuthStorageEventData } from "./utils/AuthService";
import theme from "./theme"; import theme from "./theme";
import { ROUTES, DASHBOARD_ROUTE } from "./routes"; import { ROUTES, DASHBOARD_ROUTE } from "./routes";
import "./shoelace"; import "./shoelace";
@ -64,45 +64,37 @@ export class App extends LiteElement {
@state() @state()
private isRegistrationEnabled?: boolean; private isRegistrationEnabled?: boolean;
constructor() { async connectedCallback() {
super(); const authState = await AuthService.initSessionStorage();
const authState = this.authService.retrieve();
if (authState) { if (authState) {
if ( this.authService.saveLogin(authState);
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.syncViewState(); this.syncViewState();
}
private syncViewState() {
this.viewState = this.router.match(
`${window.location.pathname}${window.location.search}`
);
}
connectedCallback() {
super.connectedCallback(); super.connectedCallback();
window.addEventListener("popstate", (event) => { window.addEventListener("popstate", () => {
this.syncViewState(); this.syncViewState();
}); });
this.startSyncBrowserTabs(); 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() { async firstUpdated() {
if (this.authService.authState) { if (this.authService.authState) {
this.updateUserInfo(); this.updateUserInfo();
@ -605,7 +597,7 @@ export class App extends LiteElement {
onLoggedIn(event: LoggedInEvent) { onLoggedIn(event: LoggedInEvent) {
const { detail } = event; const { detail } = event;
this.authService.startPersist({ this.authService.saveLogin({
username: detail.username, username: detail.username,
headers: detail.headers, headers: detail.headers,
tokenExpiresAt: detail.tokenExpiresAt, tokenExpiresAt: detail.tokenExpiresAt,
@ -728,52 +720,23 @@ export class App extends LiteElement {
} }
private startSyncBrowserTabs() { private startSyncBrowserTabs() {
// TODO remove this line after this change has been deployed AuthService.broadcastChannel.addEventListener(
// for more than 24 hours "message",
window.localStorage.removeItem(AuthService.storageKey); ({ data }: { data: AuthStorageEventData }) => {
if (data.name === "auth_storage") {
// Sync local auth state across window/tabs if (data.value !== AuthService.storage.getItem()) {
// Notify any already open windows that new window is open if (data.value) {
AuthService.broadcastChannel.postMessage({ name: "need_auth" }); this.authService.saveLogin(JSON.parse(data.value));
AuthService.broadcastChannel.addEventListener("message", ({ data }) => { this.updateUserInfo();
if (data.name === "need_auth") { this.syncViewState();
// Share auth with newly opened tab } else {
const auth = AuthService.storage.getItem(); this.authService.logout();
if (auth) { this.navigate(ROUTES.login);
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));
} }
} }
} }
}); );
// 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", loginWithRedirect: "/log-in?redirectUrl",
forgotPassword: "/log-in/forgot-password", forgotPassword: "/log-in/forgot-password",
resetPassword: "/reset-password?token", resetPassword: "/reset-password?token",
myAccount: "/my-account",
accountSettings: "/account/settings", accountSettings: "/account/settings",
archives: "/archives", archives: "/archives",
archive: "/archives/:id/:tab", archive: "/archives/:id/:tab",

View File

@ -27,6 +27,24 @@ export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {
readonly detail: T; 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 // Check for token freshness every 5 minutes
const FRESHNESS_TIMER_INTERVAL = 60 * 1000 * 5; const FRESHNESS_TIMER_INTERVAL = 60 * 1000 * 5;
@ -45,22 +63,20 @@ export default class AuthService {
}, },
setItem(newValue: string) { setItem(newValue: string) {
const oldValue = AuthService.storage.getItem(); const oldValue = AuthService.storage.getItem();
if (oldValue === newValue) return;
window.sessionStorage.setItem(AuthService.storageKey, newValue); window.sessionStorage.setItem(AuthService.storageKey, newValue);
AuthService.broadcastChannel.postMessage({ AuthService.broadcastChannel.postMessage(<AuthStorageEventData>{
name: "storage", name: "auth_storage",
key: AuthService.storageKey, value: newValue,
oldValue,
newValue,
}); });
}, },
removeItem() { removeItem() {
const oldValue = AuthService.storage.getItem(); const oldValue = AuthService.storage.getItem();
if (!oldValue) return;
window.sessionStorage.removeItem(AuthService.storageKey); window.sessionStorage.removeItem(AuthService.storageKey);
AuthService.broadcastChannel.postMessage({ AuthService.broadcastChannel.postMessage(<AuthStorageEventData>{
name: "storage", name: "auth_storage",
key: AuthService.storageKey, value: null,
oldValue,
newValue: null,
}); });
}, },
}; };
@ -133,41 +149,108 @@ export default class AuthService {
throw new Error(AuthService.unsupportedAuthErrorCode); 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(); const auth = AuthService.storage.getItem();
if (auth) { if (auth) {
this._authState = JSON.parse(auth); return JSON.parse(auth);
this.checkFreshness();
} }
return this._authState; return null;
} }
startPersist(auth: Auth) { /**
if (auth) { * Retrieve shared session from another tab/window
this.persist(auth); **/
this.startFreshnessCheck(); private static async getSharedSessionAuth(): Promise<AuthState> {
} else { return new Promise((resolve) => {
console.warn("No authState to persist"); // 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); window.clearTimeout(this.timerId);
this.checkFreshness(); this.checkFreshness();
} }
cancelFreshnessCheck() { private cancelFreshnessCheck() {
window.clearTimeout(this.timerId); window.clearTimeout(this.timerId);
this.timerId = undefined; this.timerId = undefined;
} }
logout() {
this.cancelFreshnessCheck();
this.revoke();
}
private revoke() { private revoke() {
this._authState = null; this._authState = null;
AuthService.storage.removeItem(); AuthService.storage.removeItem();
@ -179,7 +262,6 @@ export default class AuthService {
headers: auth.headers, headers: auth.headers,
tokenExpiresAt: auth.tokenExpiresAt, tokenExpiresAt: auth.tokenExpiresAt,
}; };
AuthService.storage.setItem(JSON.stringify(this._authState)); AuthService.storage.setItem(JSON.stringify(this._authState));
} }