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:
parent
da8260a028
commit
e7f1a00411
@ -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();
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user