Refactor & sync user session across tab/windows (#370)

This commit is contained in:
sua yoo 2022-11-15 19:49:18 -08:00 committed by GitHub
parent 40054d1501
commit 4d4ce40443
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 121 additions and 50 deletions

View File

@ -27,7 +27,7 @@ describe("browsertrix-app", () => {
});
it("sets auth state from session storage", async () => {
stub(window.localStorage, "getItem").callsFake((key) => {
stub(window.sessionStorage, "getItem").callsFake((key) => {
if (key === "btrix.auth")
return JSON.stringify({
username: "test-auth@example.com",
@ -42,7 +42,7 @@ describe("browsertrix-app", () => {
});
it("sets user info", async () => {
stub(window.localStorage, "getItem").callsFake((key) => {
stub(window.sessionStorage, "getItem").callsFake((key) => {
if (key === "btrix.auth")
return JSON.stringify({
username: "test-auth@example.com",

View File

@ -99,6 +99,8 @@ export class App extends LiteElement {
window.addEventListener("popstate", (event) => {
this.syncViewState();
});
this.startSyncBrowserTabs();
}
async firstUpdated() {
@ -475,6 +477,7 @@ export class App extends LiteElement {
return html`<btrix-account-settings
class="w-full max-w-screen-lg mx-auto p-2 md:py-8 box-border"
@navigate="${this.onNavigateTo}"
@logged-in=${this.onLoggedIn}
@need-login="${this.onNeedLogin}"
.authState="${this.authService.authState}"
.userInfo="${this.userInfo}"
@ -723,6 +726,54 @@ 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,
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();
}
});
}
}
customElements.define("browsertrix-app", App);

View File

@ -9,11 +9,7 @@ export type Auth = {
tokenExpiresAt: number;
};
type Session = {
sessionExpiresAt: number;
};
export type AuthState = (Auth & Session) | null;
export type AuthState = Auth | null;
type JWT = {
user_id: string;
@ -33,8 +29,6 @@ export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {
// Check for token freshness every 5 minutes
const FRESHNESS_TIMER_INTERVAL = 60 * 1000 * 5;
// Hardcode 24h expiry for now
const SESSION_LIFETIME = 1000 * 60 * 60 * 24;
export default class AuthService {
private timerId?: number;
@ -44,6 +38,33 @@ export default class AuthService {
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();
window.sessionStorage.setItem(AuthService.storageKey, newValue);
AuthService.broadcastChannel.postMessage({
name: "storage",
key: AuthService.storageKey,
oldValue,
newValue,
});
},
removeItem() {
const oldValue = AuthService.storage.getItem();
window.sessionStorage.removeItem(AuthService.storageKey);
AuthService.broadcastChannel.postMessage({
name: "storage",
key: AuthService.storageKey,
oldValue,
newValue: null,
});
},
};
get authState() {
return this._authState;
}
@ -113,7 +134,7 @@ export default class AuthService {
}
retrieve(): AuthState {
const auth = window.localStorage.getItem(AuthService.storageKey);
const auth = AuthService.storage.getItem();
if (auth) {
this._authState = JSON.parse(auth);
@ -126,20 +147,30 @@ export default class AuthService {
startPersist(auth: Auth) {
if (auth) {
this.persist(auth);
this.checkFreshness();
this.startFreshnessCheck();
} else {
console.warn("No authState to persist");
}
}
logout() {
startFreshnessCheck() {
window.clearTimeout(this.timerId);
this.checkFreshness();
}
cancelFreshnessCheck() {
window.clearTimeout(this.timerId);
this.timerId = undefined;
}
logout() {
this.cancelFreshnessCheck();
this.revoke();
}
private revoke() {
this._authState = null;
window.localStorage.removeItem(AuthService.storageKey);
AuthService.storage.removeItem();
}
private persist(auth: Auth) {
@ -147,61 +178,50 @@ export default class AuthService {
username: auth.username,
headers: auth.headers,
tokenExpiresAt: auth.tokenExpiresAt,
sessionExpiresAt: Date.now() + SESSION_LIFETIME,
};
window.localStorage.setItem(
AuthService.storageKey,
JSON.stringify(this._authState)
);
AuthService.storage.setItem(JSON.stringify(this._authState));
}
private async checkFreshness() {
window.clearTimeout(this.timerId);
// 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.sessionExpiresAt > paddedNow) {
if (this._authState.tokenExpiresAt > paddedNow) {
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(
// "fresh! restart timer tokenExpiresAt:",
// "refreshed. restart timer tokenExpiresAt:",
// new Date(this._authState.tokenExpiresAt)
// );
// console.debug("fresh! restart timer paddedNow:", new Date(paddedNow));
// console.debug(
// "refreshed. 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);
}
} catch (e) {
console.debug(e);
}
} else {
console.info("Session expired, logging out");
this.logout();
}
}