diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts index a172ff9..c40863c 100644 --- a/web/src/lib/client.ts +++ b/web/src/lib/client.ts @@ -1,5 +1,15 @@ import { get, writable, type Writable } from "svelte/store" -import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "./const" +import { + API_BASE_ADDR, + AT_EXP_MS, + COOKIE_SAME_SITE, + COOKIE_SECURE, + CSRF_EXP_MS, + REFRESH_BUF, + UUID_REGEX, + VIEW_COOKIE_DOMAIN, + VIEW_COOKIE_PATH +} from "./const" import { goto } from "$app/navigation" import { usersPagination } from "./pages" @@ -84,7 +94,7 @@ interface ApiFullVersionResponse { created_at: string } -// Some of these could just be local variables as they're not used globally +// Some of these could just be local variables as not all of them are being used globally export const currentUser: Writable = writable(null) export const currentFullNote: Writable = writable(null) export const availableNotes: Writable = writable(null) @@ -124,7 +134,13 @@ class ApiClient { // will be automatically included to corresponding requests by browser and CSRF rotation is // handled by the `refreshAccessToken` method if (options.useBearerAuth) { - await this.checkAndRefreshAccessToken() + try { + await this.checkAndRefreshAccessToken() + } catch (err) { + console.log("refresh attempt not successful") + await this.handleLocalLogout() + throw new Error("Session expired, please authenticate again.") + } } return await fn() } catch (err) { @@ -167,8 +183,10 @@ class ApiClient { private async checkAndRefreshAccessToken(): Promise { // Notably we must check whether we even have an authentication cookie present // as that's obviously required for completing the token rotation procedure - if (this.refreshInProgress || !this.isAuthCookieValid) { + if (this.refreshInProgress) { return + } else if (!this.isAuthCookieValid()) { + throw new Error("Session expired, please authenticate again.") } const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime() @@ -182,6 +200,8 @@ class ApiClient { this.refreshInProgress = true await this.refreshAccessToken() this.refreshInProgress = false + } else { + console.log("no need to rotate tokens") } } @@ -246,9 +266,13 @@ class ApiClient { private async handleLocalLogout(): Promise { this.lastAtUpdate = new Date(0) this.lastCsrfUpdate = new Date(0) + currentUser.set(null) accessToken.set(null) csrfToken.set(null) + + this.deleteViewCookie() + await goto("/login") } @@ -259,23 +283,47 @@ class ApiClient { private isAuthCookieValid(): boolean { const now = new Date() - const cookies = document.cookie.split(";") + const viewCookie = this.getCookieValue(this.viewCookieName) - for (let cookie of cookies) { - const [name, value] = cookie.trim().split("=") - - if (name === this.viewCookieName) { - const expirationTimestamp = parseInt(value, 10) - if (!isNaN(expirationTimestamp)) { - const expirationDate = new Date(expirationTimestamp * 1000) - console.log(`auth cookie expirationDate: ${expirationDate}`) - return now < expirationDate - } - } + if (!viewCookie) { + console.log("view cookie not found") + return false } - // Fallback when the cookie can't be found - return false + const value = viewCookie.split("=")[1] + const expirationTimestamp = parseInt(value, 10) + + if (isNaN(expirationTimestamp)) { + console.log(`invalid expiration timestamp: ${value}`) + return false + } + + const expirationDate = new Date(expirationTimestamp * 1000) + console.log(`auth cookie expiration: ${expirationDate}`) + + return now < expirationDate + } + + private deleteViewCookie() { + const viewCookie = this.getCookieValue(this.viewCookieName) + if (!viewCookie) { + return + } + + // Overwrite the view cookie with details that match with the real cookie + document.cookie = `${this.viewCookieName}=;path=${VIEW_COOKIE_PATH};domain=${VIEW_COOKIE_DOMAIN};expires=Thu, 01 Jan 1970 00:00:00 GMT;${COOKIE_SECURE ? "secure;" : ""}sameSite=${COOKIE_SAME_SITE}` + } + + private getCookieValue(cookieName: string): string | null { + const cookieString = document.cookie + const cookies = cookieString.split(";").map((cookie) => cookie.trim()) + const cookieValue = cookies.find((cookie) => cookie.startsWith(`${cookieName}=`)) + + if (!cookieValue) { + return null + } + + return cookieValue } private deserializeUser(apiResponse: ApiUserResponse): User { @@ -430,7 +478,9 @@ class ApiClient { }) const data = await this.handleResponse(response, { useBearerAuth: false }) const user = this.deserializeUser(data) + console.log(user) + currentUser.set(user) }, { useBearerAuth: true } diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index b1113b6..7c083b5 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -2,14 +2,21 @@ // will automatically be proxied to the correct destination export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api" -// Probably shouldn't be hardcoded, but instead read from .env +// Lifetimes of *in-memory* authentication tokens export const AT_EXP_MS = 15 * 60 * 1000 // 15 min. export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h. export const REFRESH_BUF = 30 * 1000 // 30 s. +// User registration password restrictions export const MIN_PASSWORD_LENGTH = 12 export const MAX_PASSWORD_LENGTH = 72 export const MIN_PASSWORD_ENTROPY = 60.0 +export const ENTROPY_CLASSES: Array<[RegExp, number]> = [ + [/[a-z]/, 24], // 26 + [/[A-Z]/, 24], // 26 + [/\d/, 10], + [/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40 +] export const TITLE_MAX_LENGTH = 150 @@ -18,10 +25,8 @@ export const UUID_REGEX = new RegExp( "i" ) -// Underestimation compared to the backend to prevent mismatches when registering -export const ENTROPY_CLASSES: Array<[RegExp, number]> = [ - [/[a-z]/, 24], // 26 - [/[A-Z]/, 24], // 26 - [/\d/, 10], - [/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40 -] +// View cookie configuration (holds UNIX timestamp value of the actual refresh token cookie's expiration date) +export const VIEW_COOKIE_PATH = import.meta.env.VITE_VIEW_COOKIE_PATH || "/" +export const VIEW_COOKIE_DOMAIN = import.meta.env.VITE_VIEW_COOKIE_DOMAIN || "localhost" +export const COOKIE_SAME_SITE = import.meta.env.VITE_COOKIE_SAME_SITE || "strict" +export const COOKIE_SECURE = import.meta.env.PROD ? true : false