From e988b02f0c6bda9fbe20b4ed9d137b3a764abf55 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 9 Sep 2025 03:18:53 -0700 Subject: [PATCH] Add auth helper libraries --- src/lib/auth/cookies.ts | 82 ++++++++++++++++++++++++++++++++++++ src/lib/auth/map.ts | 25 +++++++++++ src/lib/auth/oauth.schema.ts | 16 +++++++ src/lib/auth/oauth.ts | 30 +++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 src/lib/auth/cookies.ts create mode 100644 src/lib/auth/map.ts create mode 100644 src/lib/auth/oauth.schema.ts create mode 100644 src/lib/auth/oauth.ts diff --git a/src/lib/auth/cookies.ts b/src/lib/auth/cookies.ts new file mode 100644 index 00000000..38c8c569 --- /dev/null +++ b/src/lib/auth/cookies.ts @@ -0,0 +1,82 @@ +import type { Cookies } from '@sveltejs/kit' +import type { AccountCookie } from '$lib/types/AccountCookie' +import type { UserCookie } from '$lib/types/UserCookie' + +export const ACCOUNT_COOKIE = 'account' +export const USER_COOKIE = 'user' +export const REFRESH_COOKIE = 'refresh' +const SIXTY_DAYS = 60 * 60 * 24 * 60 + +export function setAccountCookie( + cookies: Cookies, + data: AccountCookie, + { secure, expires }: { secure: boolean; expires: Date } +) { + cookies.set(ACCOUNT_COOKIE, JSON.stringify(data), { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure, + expires, + maxAge: SIXTY_DAYS + }) +} + +export function setUserCookie( + cookies: Cookies, + data: UserCookie, + { secure, expires }: { secure: boolean; expires: Date } +) { + cookies.set(USER_COOKIE, JSON.stringify(data), { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure, + expires, + maxAge: SIXTY_DAYS + }) +} + +export function setRefreshCookie( + cookies: Cookies, + data: string, + { secure, expires }: { secure: boolean; expires?: Date } +) { + cookies.set(REFRESH_COOKIE, data, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure, + ...(expires ? { expires } : {}) + }) +} + +export function getAccountFromCookies(cookies: Cookies): AccountCookie | null { + const raw = cookies.get(ACCOUNT_COOKIE) + if (!raw) return null + try { + return JSON.parse(raw) as AccountCookie + } catch { + return null + } +} + +export function getUserFromCookies(cookies: Cookies): UserCookie | null { + const raw = cookies.get(USER_COOKIE) + if (!raw) return null + try { + return JSON.parse(raw) as UserCookie + } catch { + return null + } +} + +export function getRefreshFromCookies(cookies: Cookies): string | null { + return cookies.get(REFRESH_COOKIE) ?? null +} + +export function clearAuthCookies(cookies: Cookies) { + cookies.delete(ACCOUNT_COOKIE, { path: '/' }) + cookies.delete(USER_COOKIE, { path: '/' }) + cookies.delete(REFRESH_COOKIE, { path: '/' }) +} diff --git a/src/lib/auth/map.ts b/src/lib/auth/map.ts new file mode 100644 index 00000000..00d05ba4 --- /dev/null +++ b/src/lib/auth/map.ts @@ -0,0 +1,25 @@ +import type { OAuthLoginResponse } from './oauth' +import type { UserInfoResponse } from '$lib/api/resources/users' +import type { AccountCookie } from '$lib/types/AccountCookie' +import type { UserCookie } from '$lib/types/UserCookie' + +export function buildCookies(oauth: OAuthLoginResponse, info: UserInfoResponse) { + const accessTokenExpiresAt = new Date((oauth.created_at + oauth.expires_in) * 1000) + + const account: AccountCookie = { + userId: info.id, + username: info.username, + token: oauth.access_token, + role: info.role + } + + const user: UserCookie = { + picture: info.avatar.picture ?? '', + element: info.avatar.element ?? '', + language: info.language ?? 'en', + gender: info.gender ?? 0, + theme: info.theme ?? 'system' + } + + return { account, user, accessTokenExpiresAt, refresh: oauth.refresh_token } +} diff --git a/src/lib/auth/oauth.schema.ts b/src/lib/auth/oauth.schema.ts new file mode 100644 index 00000000..d9094899 --- /dev/null +++ b/src/lib/auth/oauth.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const OAuthLoginResponseSchema = z.object({ + access_token: z.string(), + token_type: z.literal('Bearer'), + expires_in: z.number().int().positive(), + refresh_token: z.string(), + created_at: z.number().int().nonnegative(), + user: z.object({ + id: z.string(), + username: z.string(), + role: z.number().int() + }) +}) + +export type OAuthLoginResponse = z.infer diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts new file mode 100644 index 00000000..4920b433 --- /dev/null +++ b/src/lib/auth/oauth.ts @@ -0,0 +1,30 @@ +import type { FetchLike } from '$lib/api/core' +import { OAUTH_BASE } from '$lib/config' + +export interface OAuthLoginResponse { + access_token: string + token_type: 'Bearer' + expires_in: number + refresh_token: string + created_at: number + user: { + id: string + username: string + role: number + } +} +export async function passwordGrantLogin( + fetchFn: FetchLike, + body: { email: string; password: string; grant_type: 'password' } +): Promise { + const url = `${OAUTH_BASE}/token` + const res = await fetchFn(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + + if (res.status === 401) throw new Error('unauthorized') + if (!res.ok) throw new Error(`oauth_error_${res.status}`) + return res.json() as Promise +}