Refactor & sync user session across tab/windows (#370)
This commit is contained in:
parent
40054d1501
commit
4d4ce40443
@ -27,7 +27,7 @@ describe("browsertrix-app", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets auth state from session storage", async () => {
|
it("sets auth state from session storage", async () => {
|
||||||
stub(window.localStorage, "getItem").callsFake((key) => {
|
stub(window.sessionStorage, "getItem").callsFake((key) => {
|
||||||
if (key === "btrix.auth")
|
if (key === "btrix.auth")
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
username: "test-auth@example.com",
|
username: "test-auth@example.com",
|
||||||
@ -42,7 +42,7 @@ describe("browsertrix-app", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets user info", async () => {
|
it("sets user info", async () => {
|
||||||
stub(window.localStorage, "getItem").callsFake((key) => {
|
stub(window.sessionStorage, "getItem").callsFake((key) => {
|
||||||
if (key === "btrix.auth")
|
if (key === "btrix.auth")
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
username: "test-auth@example.com",
|
username: "test-auth@example.com",
|
||||||
|
@ -99,6 +99,8 @@ export class App extends LiteElement {
|
|||||||
window.addEventListener("popstate", (event) => {
|
window.addEventListener("popstate", (event) => {
|
||||||
this.syncViewState();
|
this.syncViewState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.startSyncBrowserTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
@ -475,6 +477,7 @@ export class App extends LiteElement {
|
|||||||
return html`<btrix-account-settings
|
return html`<btrix-account-settings
|
||||||
class="w-full max-w-screen-lg mx-auto p-2 md:py-8 box-border"
|
class="w-full max-w-screen-lg mx-auto p-2 md:py-8 box-border"
|
||||||
@navigate="${this.onNavigateTo}"
|
@navigate="${this.onNavigateTo}"
|
||||||
|
@logged-in=${this.onLoggedIn}
|
||||||
@need-login="${this.onNeedLogin}"
|
@need-login="${this.onNeedLogin}"
|
||||||
.authState="${this.authService.authState}"
|
.authState="${this.authService.authState}"
|
||||||
.userInfo="${this.userInfo}"
|
.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);
|
customElements.define("browsertrix-app", App);
|
||||||
|
@ -9,11 +9,7 @@ export type Auth = {
|
|||||||
tokenExpiresAt: number;
|
tokenExpiresAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Session = {
|
export type AuthState = Auth | null;
|
||||||
sessionExpiresAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuthState = (Auth & Session) | null;
|
|
||||||
|
|
||||||
type JWT = {
|
type JWT = {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@ -33,8 +29,6 @@ export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {
|
|||||||
|
|
||||||
// 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;
|
||||||
// Hardcode 24h expiry for now
|
|
||||||
const SESSION_LIFETIME = 1000 * 60 * 60 * 24;
|
|
||||||
|
|
||||||
export default class AuthService {
|
export default class AuthService {
|
||||||
private timerId?: number;
|
private timerId?: number;
|
||||||
@ -44,6 +38,33 @@ export default class AuthService {
|
|||||||
static unsupportedAuthErrorCode = "UNSUPPORTED_AUTH_TYPE";
|
static unsupportedAuthErrorCode = "UNSUPPORTED_AUTH_TYPE";
|
||||||
static loggedInEvent = "logged-in";
|
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() {
|
get authState() {
|
||||||
return this._authState;
|
return this._authState;
|
||||||
}
|
}
|
||||||
@ -113,7 +134,7 @@ export default class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retrieve(): AuthState {
|
retrieve(): AuthState {
|
||||||
const auth = window.localStorage.getItem(AuthService.storageKey);
|
const auth = AuthService.storage.getItem();
|
||||||
|
|
||||||
if (auth) {
|
if (auth) {
|
||||||
this._authState = JSON.parse(auth);
|
this._authState = JSON.parse(auth);
|
||||||
@ -126,20 +147,30 @@ export default class AuthService {
|
|||||||
startPersist(auth: Auth) {
|
startPersist(auth: Auth) {
|
||||||
if (auth) {
|
if (auth) {
|
||||||
this.persist(auth);
|
this.persist(auth);
|
||||||
this.checkFreshness();
|
this.startFreshnessCheck();
|
||||||
} else {
|
} else {
|
||||||
console.warn("No authState to persist");
|
console.warn("No authState to persist");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
startFreshnessCheck() {
|
||||||
window.clearTimeout(this.timerId);
|
window.clearTimeout(this.timerId);
|
||||||
|
this.checkFreshness();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelFreshnessCheck() {
|
||||||
|
window.clearTimeout(this.timerId);
|
||||||
|
this.timerId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.cancelFreshnessCheck();
|
||||||
this.revoke();
|
this.revoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private revoke() {
|
private revoke() {
|
||||||
this._authState = null;
|
this._authState = null;
|
||||||
window.localStorage.removeItem(AuthService.storageKey);
|
AuthService.storage.removeItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
private persist(auth: Auth) {
|
private persist(auth: Auth) {
|
||||||
@ -147,61 +178,50 @@ export default class AuthService {
|
|||||||
username: auth.username,
|
username: auth.username,
|
||||||
headers: auth.headers,
|
headers: auth.headers,
|
||||||
tokenExpiresAt: auth.tokenExpiresAt,
|
tokenExpiresAt: auth.tokenExpiresAt,
|
||||||
sessionExpiresAt: Date.now() + SESSION_LIFETIME,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.localStorage.setItem(
|
AuthService.storage.setItem(JSON.stringify(this._authState));
|
||||||
AuthService.storageKey,
|
|
||||||
JSON.stringify(this._authState)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkFreshness() {
|
private async checkFreshness() {
|
||||||
window.clearTimeout(this.timerId);
|
|
||||||
|
|
||||||
// console.debug("checkFreshness authState:", this._authState);
|
// console.debug("checkFreshness authState:", this._authState);
|
||||||
|
|
||||||
if (!this._authState) return;
|
if (!this._authState) return;
|
||||||
const paddedNow = Date.now() + FRESHNESS_TIMER_INTERVAL - 500; // tweak padding to account for API fetch time
|
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(
|
// console.debug(
|
||||||
// "fresh! restart timer tokenExpiresAt:",
|
// "refreshed. restart timer tokenExpiresAt:",
|
||||||
// new Date(this._authState.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
|
// Restart timer
|
||||||
this.timerId = window.setTimeout(() => {
|
this.timerId = window.setTimeout(() => {
|
||||||
this.checkFreshness();
|
this.checkFreshness();
|
||||||
}, FRESHNESS_TIMER_INTERVAL);
|
}, FRESHNESS_TIMER_INTERVAL);
|
||||||
} else {
|
} catch (e) {
|
||||||
try {
|
console.debug(e);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.info("Session expired, logging out");
|
|
||||||
this.logout();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user