notatest/web/src/lib/client.ts

600 lines
16 KiB
TypeScript

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 { goto } from "$app/navigation"
import { usersPagination } from "./pages"
interface UserResponse {
id: string
username: string
isAdmin: boolean
createdAt: Date
updatedAt: Date
}
export interface FullNoteResponse {
id: string
owner: string
title: string
content: string
versionNumber: number
versionCreatedAt: Date
noteCreatedAt: Date
noteUpdatedAt: Date
}
export interface NoteMetadataResponse {
id: string
owner: string
title: string
updatedAt: Date
}
interface NewNoteResponse {
title: string
content: string
}
interface VersionMetadataResponse {
versionID: string
title: string
}
interface FullVersionResponse {
versionID: string
title: string
content: string
versionNumber: number
createdAt: Date
}
export const currentUser: Writable<UserResponse | null> = writable(null)
export const currentFullNote: Writable<FullNoteResponse | null> = writable(null)
export const availableNotes: Writable<NoteMetadataResponse[] | null> = writable(null)
export const accessToken: Writable<string | null> = writable(null)
export const csrfToken: Writable<string | null> = writable(null)
export const isPending: Writable<boolean> = writable(false)
export const cError: Writable<string | null> = writable(null)
class ApiClient {
private viewCookieName: string
private baseUrl: string
private lastAtUpdate = new Date(0)
private lastCsrfUpdate = new Date(0)
private refreshInProgress = false
constructor(baseUrl: string) {
this.baseUrl = baseUrl
this.viewCookieName = "notatest.expires_at"
}
private async handleRequest<T>(
fn: () => Promise<T>,
options: { useBearerAuth: boolean }
): Promise<T | undefined> {
isPending.set(true)
cError.set(null)
// NOTE: If `handleResponse` is used, errors thrown from it will be caught here
try {
// Only bearers (JWT access tokens) need to be considered here as the refresh token cookies
// will be automatically included to corresponding requests by browser and CSRF rotation is
// handled by the `refreshAccessToken` method
if (options.useBearerAuth) {
await this.checkAndRefreshAccessToken()
}
return await fn()
} catch (err) {
cError.set(err instanceof Error ? err.message : "Unknown error")
console.log(`error: ${get(cError)}`)
} finally {
isPending.set(false)
}
}
// Should be attached to routes that handle authentication with the bearer token (access token)
private async handleResponse<T>(
response: Response,
options: { useBearerAuth: boolean }
): Promise<T> {
if (!response.ok) {
if (response.status === 401 && options.useBearerAuth) {
// This should never happen due to the token expiration checks we make client-side,
// but it's still good to have as a fallback
try {
console.log("unexpected 401 caught, attempting refresh")
await this.checkAndRefreshAccessToken()
} catch (err) {
console.log("refresh attempt not successful")
await this.handleLocalLogout()
throw new Error("Session expired, please authenticate again.")
}
}
// Capitalize the error message and display (only if not 401)
const { error } = await response.json()
const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "."
throw new Error(dError)
}
return await response.json()
}
private async checkAndRefreshAccessToken(): Promise<void> {
// 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) {
return
}
const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime()
const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF
console.log(`timeSinceUpdate: ${timeSinceUpdate}`)
if (needsRefresh) {
console.log("running token refresh attempt")
this.refreshInProgress = true
await this.refreshAccessToken()
this.refreshInProgress = false
}
}
private async refreshAccessToken(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, {
method: "POST",
headers: {
...(await this.getCsrfHeader())
},
credentials: "include"
})
if (!response.ok) {
await this.handleLocalLogout()
throw new Error("Refreshing token failed.")
}
const { access_token: newToken } = await response.json()
accessToken.set(newToken)
this.lastAtUpdate = new Date()
},
{ useBearerAuth: false }
)
}
private async getCsrfHeader(): Promise<HeadersInit> {
let token = get(csrfToken)
const timeSinceUpdate = Date.now() - this.lastCsrfUpdate.getTime()
const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF
if (!token || needsRefresh) {
console.log("refreshing csrf token")
await this.refreshCsrfToken()
token = get(csrfToken)
}
return { "X-Csrf-Token": token || "" }
}
private async refreshCsrfToken(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/csrf`, {
credentials: "include"
})
if (!response.ok) {
await this.handleLocalLogout()
throw new Error("Fetching CSRF token failed.")
}
const newToken = response.headers.get("X-Csrf-Token")
csrfToken.set(newToken)
this.lastCsrfUpdate = new Date()
},
{ useBearerAuth: false }
)
}
private async handleLocalLogout(): Promise<void> {
this.lastAtUpdate = new Date(0)
this.lastCsrfUpdate = new Date(0)
currentUser.set(null)
accessToken.set(null)
csrfToken.set(null)
await goto("/login")
}
private getAuthHeader(): HeadersInit {
const token = get(accessToken)
return { Authorization: `Bearer ${token}` }
}
private isAuthCookieValid(): boolean {
const now = new Date()
const cookies = document.cookie.split(";")
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
}
}
}
// Fallback when the cookie can't be found
return false
}
public async register(username: string, password: string): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
})
// Can't overwrite `username` parameter
const data = await this.handleResponse<{
id: string
username: string
}>(response, { useBearerAuth: false })
console.log(`${data.username} -> ${data.id}`)
await goto("/login")
},
{ useBearerAuth: false }
)
}
public async login(username: string, password: string): Promise<void> {
return this.handleRequest(
async () => {
const params = new URLSearchParams()
params.append("includeUser", "true")
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include"
})
const { access_token: token, user } = await this.handleResponse<{
access_token: string
user: UserResponse
}>(response, { useBearerAuth: false })
accessToken.set(token)
currentUser.set(user || null)
this.lastAtUpdate = new Date()
console.log(user)
goto("/")
},
{ useBearerAuth: false }
)
}
public async logout(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/logout`, {
method: "POST",
headers: {
...this.getAuthHeader()
},
credentials: "include"
})
if (response.status === 204) {
console.log("logout successful")
await this.handleLocalLogout()
return
}
await this.handleResponse<void>(response, { useBearerAuth: false })
await this.handleLocalLogout()
},
{ useBearerAuth: true }
)
}
public async getCurrentUser(): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/me`, {
headers: {
...this.getAuthHeader()
}
})
const user = await this.handleResponse<UserResponse>(response, { useBearerAuth: false })
currentUser.set(user)
},
{ useBearerAuth: true }
)
}
public async updateCurrentUserPassword(oldPassword: string, newPassword: string): Promise<void> {
return this.handleRequest(
async () => {
const data = {
old_password: oldPassword,
new_password: newPassword
}
const response = await fetch(`${this.baseUrl}/auth/owner`, {
method: "PUT",
headers: {
...this.getAuthHeader()
},
body: JSON.stringify(data)
})
const { accessToken: token, user } = await this.handleResponse<{
accessToken: string
user: UserResponse
}>(response, { useBearerAuth: false })
accessToken.set(token)
currentUser.set(user || null)
this.lastAtUpdate = new Date()
console.log(user)
},
{ useBearerAuth: true }
)
}
public async deleteCurrentUser(password: string): Promise<void> {
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/owner`, {
method: "DELETE",
headers: {
...this.getAuthHeader(),
credentials: "include"
},
body: JSON.stringify({ password })
})
if (response.status === 204) {
console.log("deletion successful")
await this.handleLocalLogout()
return
}
await this.handleResponse<void>(response, { useBearerAuth: false })
await this.handleLocalLogout()
},
{ useBearerAuth: true }
)
}
public async adminListAll(): Promise<UserResponse[] | undefined> {
const user = get(currentUser)
if (!user || !user.isAdmin) {
throw new Error("Admin privileges required.")
}
return this.handleRequest(
async () => {
const params = new URLSearchParams()
params.append("limit", `${get(usersPagination).pageSize}`)
params.append("offset", `${get(usersPagination).currentPage}`)
const response = await fetch(`${this.baseUrl}/auth/admin/all?${params}`, {
headers: {
...this.getAuthHeader()
}
})
const users = await this.handleResponse<UserResponse[]>(response, { useBearerAuth: false })
console.log(`admin: got ${users.length} user results`)
return users
},
{ useBearerAuth: true }
)
}
public async adminDeleteUser(userID: string): Promise<void> {
const user = get(currentUser)
if (!user || !user.isAdmin) {
throw new Error("Admin privileges required.")
}
if (!UUID_REGEX.test(userID)) {
throw new Error("Invalid user ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/auth/admin/${userID}`, {
method: "DELETE",
headers: {
...this.getAuthHeader()
}
})
if (response.status === 204) {
console.log("admin: deletion successful")
return
}
await this.handleResponse<void>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async listNotes(): Promise<NoteMetadataResponse[] | undefined> {
return this.handleRequest(
async () => {
const params = new URLSearchParams()
params.append("limit", `${get(usersPagination).pageSize}`)
params.append("offset", `${get(usersPagination).currentPage}`)
const response = await fetch(`${this.baseUrl}/notes?${params}`, {
headers: {
...this.getAuthHeader()
}
})
// TODO: handle case where no notes have yet been created, i.e. `notes` from response is `null`
const notes = await this.handleResponse<NoteMetadataResponse[]>(response, {
useBearerAuth: false
})
console.log(`got ${notes.length} note metadata results`)
return notes
},
{ useBearerAuth: true }
)
}
public async createNote(): Promise<NewNoteResponse | undefined> {
// NOTE: The initial note version doesn't allow any user input, the first user-made modification
// is applied through the version creation endpoint
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes`, {
method: "POST",
headers: {
...this.getAuthHeader()
}
})
return await this.handleResponse<NewNoteResponse>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async getFullNote(noteID: string): Promise<FullNoteResponse | undefined> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/${noteID}`, {
headers: {
...this.getAuthHeader()
}
})
return await this.handleResponse<FullNoteResponse>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async deleteNote(noteID: string): Promise<void> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}`, {
method: "DELETE",
headers: {
...this.getAuthHeader()
}
})
if (response.status === 204) {
console.log("deletion successful")
return
}
await this.handleResponse<void>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async getNoteHistory(noteID: string): Promise<VersionMetadataResponse[] | undefined> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
headers: {
...this.getAuthHeader()
}
})
const versions = await this.handleResponse<VersionMetadataResponse[]>(response, {
useBearerAuth: false
})
console.log(`got ${versions.length} version metadata results`)
return versions
},
{ useBearerAuth: true }
)
}
public async createVersion(noteID: string, title: string, content: string): Promise<void> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
// NOTE: Title's length limit is applied in the UI component, so we don't need to worry about it here
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
method: "POST",
headers: {
...this.getAuthHeader()
}
})
if (response.status === 204) {
console.log("creation successful")
return
}
await this.handleResponse<void>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async getFullVersion(
noteID: string,
versionID: string
): Promise<FullVersionResponse | undefined> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
if (!UUID_REGEX.test(versionID)) {
throw new Error("Invalid version ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, {
headers: {
...this.getAuthHeader()
}
})
return await this.handleResponse<FullVersionResponse>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
}
export const apiClient = new ApiClient(API_BASE_ADDR)