Refresh access token in background (#48)

closes #22
This commit is contained in:
sua yoo 2021-12-03 10:00:13 -08:00 committed by GitHub
parent c48e870ffe
commit 30e40adddd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 141 additions and 52 deletions

View File

@ -6,7 +6,7 @@ import { createMachine, interpret, assign } from "@xstate/fsm";
import type { CurrentUser } from "../types/user";
import LiteElement, { html } from "../utils/LiteElement";
import { needLogin } from "../utils/auth";
import type { AuthState } from "../utils/AuthService";
import type { AuthState, Auth } from "../utils/AuthService";
import AuthService from "../utils/AuthService";
@localized()
@ -333,7 +333,7 @@ export class AccountSettings extends LiteElement {
this.formStateService.send("SUBMIT");
const { formData } = event.detail;
let nextAuthState: AuthState = null;
let nextAuthState: Auth | null = null;
try {
nextAuthState = await AuthService.login({

View File

@ -25,20 +25,20 @@ describe("browsertrix-app", () => {
expect(el).instanceOf(App);
});
// it("sets auth state from local storage", async () => {
// stub(window.localStorage, "getItem").callsFake((key) => {
// if (key === "btrix.auth")
// return JSON.stringify({
// username: "test-auth@example.com",
// });
// return null;
// });
// const el = (await fixture("<browsertrix-app></browsertrix-app>")) as App;
it("sets auth state from session storage", async () => {
stub(window.localStorage, "getItem").callsFake((key) => {
if (key === "btrix.auth")
return JSON.stringify({
username: "test-auth@example.com",
});
return null;
});
const el = (await fixture("<browsertrix-app></browsertrix-app>")) as App;
// expect(el.authService.authState).to.eql({
// username: "test-auth@example.com",
// });
// });
expect(el.authService.authState).to.eql({
username: "test-auth@example.com",
});
});
it("sets user info", async () => {
stub(window.localStorage, "getItem").callsFake((key) => {

View File

@ -59,7 +59,7 @@ type DialogContent = {
@localized()
export class App extends LiteElement {
private router: APIRouter = new APIRouter(ROUTES);
private authService: AuthService = new AuthService();
authService: AuthService = new AuthService();
@state()
userInfo?: CurrentUser;
@ -132,7 +132,7 @@ export class App extends LiteElement {
};
} catch (err: any) {
if (err?.message === "Unauthorized") {
this.authService.revoke();
this.authService.logout();
this.navigate(ROUTES.login);
}
}
@ -371,7 +371,8 @@ export class App extends LiteElement {
const detail = event.detail || {};
const redirect = detail.redirect !== false;
this.authService.revoke();
this.authService.logout();
this.authService = new AuthService();
if (redirect) {
this.navigate("/");
@ -381,10 +382,10 @@ export class App extends LiteElement {
onLoggedIn(event: LoggedInEvent) {
const { detail } = event;
this.authService.persist({
this.authService.startPersist({
username: detail.username,
headers: detail.headers,
expiresAtTs: detail.expiresAtTs,
tokenExpiresAt: detail.tokenExpiresAt,
});
if (!detail.api) {
@ -398,12 +399,9 @@ export class App extends LiteElement {
this.updateUserInfo();
}
onNeedLogin(event?: CustomEvent<{ api: boolean }>) {
this.authService.revoke();
onNeedLogin() {
this.authService.logout();
if (event?.detail?.api) {
// TODO refresh instead of redirect
}
this.navigate(ROUTES.login);
}

View File

@ -5,10 +5,14 @@ export type Auth = {
headers: {
Authorization: string;
};
expiresAtTs: number;
tokenExpiresAt: number;
};
export type AuthState = Auth | null;
type Session = {
sessionExpiresAt: number;
};
export type AuthState = (Auth & Session) | null;
type LoggedInEventDetail = Auth & {
api?: boolean;
@ -19,7 +23,16 @@ export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {
readonly detail: T;
}
// Check for token freshness every 5 minutes
const FRESHNESS_TIMER_INTERVAL = 60 * 1000 * 5;
// TODO get expires at from server
// Hardcode 1hr expiry for now
const ACCESS_TOKEN_LIFETIME = 1000 * 60 * 60;
// Hardcode 24h expiry for now
const SESSION_LIFETIME = 1000 * 60 * 60 * 24;
export default class AuthService {
private timerId?: number;
private _authState: AuthState = null;
static storageKey = "btrix.auth";
@ -60,17 +73,24 @@ export default class AuthService {
});
}
const data = await resp.json();
const authHeaders = AuthService.parseAuthHeaders(await resp.json());
return {
username: email,
headers: authHeaders,
// TODO get expires at from server
// Hardcode 1hr expiry for now
tokenExpiresAt: Date.now() + ACCESS_TOKEN_LIFETIME,
};
}
private static parseAuthHeaders(data: {
token_type: string;
access_token: string;
}): Auth["headers"] {
if (data.token_type === "bearer" && data.access_token) {
return {
username: email,
headers: {
Authorization: `Bearer ${data.access_token}`,
},
// TODO get expires at from server
// Hardcode 1hr expiry for now
expiresAtTs: Date.now() + 3600 * 1000,
Authorization: `Bearer ${data.access_token}`,
};
}
@ -78,29 +98,104 @@ export default class AuthService {
}
retrieve(): AuthState {
const authState = window.localStorage.getItem(AuthService.storageKey);
const auth = window.localStorage.getItem(AuthService.storageKey);
if (authState) {
this._authState = JSON.parse(authState);
if (auth) {
this._authState = JSON.parse(auth);
this.checkFreshness();
}
return this._authState;
}
persist(authState: AuthState) {
if (authState) {
this._authState = authState;
window.localStorage.setItem(
AuthService.storageKey,
JSON.stringify(this.authState)
);
startPersist(auth: Auth) {
if (auth) {
this.persist(auth);
this.checkFreshness();
} else {
console.warn("No authState to persist");
}
}
revoke() {
logout() {
window.clearTimeout(this.timerId);
this.revoke();
}
private revoke() {
this._authState = null;
window.localStorage.setItem(AuthService.storageKey, "");
window.localStorage.removeItem(AuthService.storageKey);
}
private persist(auth: Auth) {
this._authState = {
username: auth.username,
headers: auth.headers,
tokenExpiresAt: auth.tokenExpiresAt,
sessionExpiresAt: Date.now() + SESSION_LIFETIME,
};
window.localStorage.setItem(AuthService.storageKey, JSON.stringify(auth));
}
private async checkFreshness() {
window.clearTimeout(this.timerId);
if (!this._authState) return;
const paddedNow = Date.now() + FRESHNESS_TIMER_INTERVAL;
if (this._authState.sessionExpiresAt > paddedNow) {
if (this._authState.tokenExpiresAt > 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;
// Restart timer
this.timerId = window.setTimeout(() => {
this.checkFreshness();
}, FRESHNESS_TIMER_INTERVAL);
} catch (e) {
console.debug(e);
}
}
} else {
this.logout();
}
}
private async refresh(): Promise<{
headers: Auth["headers"];
tokenExpiresAt: Auth["tokenExpiresAt"];
}> {
if (!this.authState) {
throw new Error("No this.authState");
}
const resp = await fetch("/api/auth/jwt/refresh", {
method: "POST",
headers: this.authState.headers,
});
if (resp.status !== 200) {
throw new APIError({
message: resp.statusText,
status: resp.status,
});
}
const authHeaders = AuthService.parseAuthHeaders(await resp.json());
return {
headers: authHeaders,
// TODO get expires at from server
// Hardcode 1hr expiry for now
tokenExpiresAt: Date.now() + ACCESS_TOKEN_LIFETIME,
};
}
}

View File

@ -43,11 +43,7 @@ export default class LiteElement extends LitElement {
if (resp.status !== 200) {
if (resp.status === 401) {
this.dispatchEvent(
new CustomEvent("need-login", {
detail: { api: true },
})
);
this.dispatchEvent(new CustomEvent("need-login"));
}
let errorMessage: string;