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