parent
c48e870ffe
commit
30e40adddd
@ -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({
|
||||
|
@ -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) => {
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user