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

View File

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

View File

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

View File

@ -5,10 +5,14 @@ export type Auth = {
headers: { headers: {
Authorization: string; Authorization: string;
}; };
expiresAtTs: number; tokenExpiresAt: number;
}; };
export type AuthState = Auth | null; type Session = {
sessionExpiresAt: number;
};
export type AuthState = (Auth & Session) | null;
type LoggedInEventDetail = Auth & { type LoggedInEventDetail = Auth & {
api?: boolean; api?: boolean;
@ -19,7 +23,16 @@ export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {
readonly detail: T; 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 { export default class AuthService {
private timerId?: number;
private _authState: AuthState = null; private _authState: AuthState = null;
static storageKey = "btrix.auth"; static storageKey = "btrix.auth";
@ -60,17 +73,24 @@ export default class AuthService {
}); });
} }
const data = await resp.json(); const authHeaders = AuthService.parseAuthHeaders(await resp.json());
if (data.token_type === "bearer" && data.access_token) {
return { return {
username: email, username: email,
headers: { headers: authHeaders,
Authorization: `Bearer ${data.access_token}`,
},
// TODO get expires at from server // TODO get expires at from server
// Hardcode 1hr expiry for now // Hardcode 1hr expiry for now
expiresAtTs: Date.now() + 3600 * 1000, 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 {
Authorization: `Bearer ${data.access_token}`,
}; };
} }
@ -78,29 +98,104 @@ export default class AuthService {
} }
retrieve(): AuthState { retrieve(): AuthState {
const authState = window.localStorage.getItem(AuthService.storageKey); const auth = window.localStorage.getItem(AuthService.storageKey);
if (authState) { if (auth) {
this._authState = JSON.parse(authState); this._authState = JSON.parse(auth);
this.checkFreshness();
} }
return this._authState; return this._authState;
} }
persist(authState: AuthState) { startPersist(auth: Auth) {
if (authState) { if (auth) {
this._authState = authState; this.persist(auth);
window.localStorage.setItem( this.checkFreshness();
AuthService.storageKey,
JSON.stringify(this.authState)
);
} else { } else {
console.warn("No authState to persist"); console.warn("No authState to persist");
} }
} }
revoke() { logout() {
window.clearTimeout(this.timerId);
this.revoke();
}
private revoke() {
this._authState = null; 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 !== 200) {
if (resp.status === 401) { if (resp.status === 401) {
this.dispatchEvent( this.dispatchEvent(new CustomEvent("need-login"));
new CustomEvent("need-login", {
detail: { api: true },
})
);
} }
let errorMessage: string; let errorMessage: string;