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