From d6b868a9fde3a17a48e1430ad195ff90ef2fd6cb Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 23 Sep 2025 22:09:14 -0700 Subject: [PATCH] add auth store and update auth flow --- src/app.d.ts | 5 + src/hooks.server.ts | 18 +++ src/lib/auth/map.ts | 3 +- src/lib/stores/auth.store.ts | 178 +++++++++++++++++++++++++++++ src/lib/types/AccountCookie.d.ts | 1 + src/routes/+layout.server.ts | 13 ++- src/routes/auth/login/+server.ts | 15 ++- src/routes/auth/refresh/+server.ts | 9 +- 8 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/lib/stores/auth.store.ts diff --git a/src/app.d.ts b/src/app.d.ts index 695bb525..8fdb99dc 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -9,6 +9,11 @@ declare global { user: UserCookie | null isAuthenticated: boolean } + auth: { + accessToken: string + user: UserCookie | null + expiresAt: string + } | null } // interface PageData {} // interface PageState {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c15f1292..98e3a8fe 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -8,12 +8,30 @@ export const handleSession: Handle = async ({ event, resolve }) => { const account = getAccountFromCookies(event.cookies) const user = getUserFromCookies(event.cookies) + // Debug logging for auth issues + if (account) { + console.log('[hooks.server] Account cookie found:', { + hasToken: !!account.token, + hasExpiresAt: !!account.expires_at, + username: account.username + }) + } + event.locals.session = { account, user, isAuthenticated: Boolean(account?.token) } + // Pass auth data for client-side auth store initialization + event.locals.auth = account?.token + ? { + accessToken: account.token, + user: user, + expiresAt: account.expires_at + } + : null + return resolve(event) } diff --git a/src/lib/auth/map.ts b/src/lib/auth/map.ts index 00d05ba4..6f352091 100644 --- a/src/lib/auth/map.ts +++ b/src/lib/auth/map.ts @@ -10,7 +10,8 @@ export function buildCookies(oauth: OAuthLoginResponse, info: UserInfoResponse) userId: info.id, username: info.username, token: oauth.access_token, - role: info.role + role: info.role, + expires_at: accessTokenExpiresAt.toISOString() } const user: UserCookie = { diff --git a/src/lib/stores/auth.store.ts b/src/lib/stores/auth.store.ts new file mode 100644 index 00000000..833a6d7a --- /dev/null +++ b/src/lib/stores/auth.store.ts @@ -0,0 +1,178 @@ +import { writable, get } from 'svelte/store' +import { browser } from '$app/environment' +import { PUBLIC_SIERO_API_URL } from '$env/static/public' + +interface UserInfo { + id: string + username: string + email?: string + avatarUrl?: string +} + +interface AuthState { + accessToken: string | null + refreshToken: string | null + user: UserInfo | null + expiresAt: Date | null + isRefreshing: boolean + isAuthenticated: boolean +} + +function createAuthStore() { + const { subscribe, set, update } = writable({ + accessToken: null, + refreshToken: null, + user: null, + expiresAt: null, + isRefreshing: false, + isAuthenticated: false + }) + + let refreshPromise: Promise | null = null + + return { + subscribe, + + setAuth: (accessToken: string, user: UserInfo, expiresIn: number) => { + const expiresAt = new Date(Date.now() + expiresIn * 1000) + set({ + accessToken, + refreshToken: null, // Managed via httpOnly cookie + user, + expiresAt, + isRefreshing: false, + isAuthenticated: true + }) + }, + + clearAuth: () => { + set({ + accessToken: null, + refreshToken: null, + user: null, + expiresAt: null, + isRefreshing: false, + isAuthenticated: false + }) + }, + + getToken: (): string | null => { + const state = get(authStore) + + // Check if token is expired + if (state.expiresAt && new Date() >= state.expiresAt) { + // Token expired, trigger refresh + if (browser && !state.isRefreshing) { + authStore.refresh() + } + return null + } + + return state.accessToken + }, + + async refresh(): Promise { + // Single in-flight refresh management + if (refreshPromise) { + return refreshPromise + } + + update(state => ({ ...state, isRefreshing: true })) + + refreshPromise = fetch(`/auth/refresh`, { + method: 'POST', + credentials: 'include', // Include httpOnly cookie + headers: { + 'Content-Type': 'application/json' + } + }) + .then(async res => { + if (!res.ok) { + throw new Error('Failed to refresh token') + } + + const data = await res.json() + + // Update auth state with new token + this.setAuth(data.access_token, data.user, data.expires_in) + + return true + }) + .catch(error => { + console.error('Token refresh failed:', error) + this.clearAuth() + + // Redirect to login on refresh failure + if (browser) { + window.location.href = '/login' + } + + return false + }) + .finally(() => { + refreshPromise = null + update(state => ({ ...state, isRefreshing: false })) + }) + + return refreshPromise + }, + + async checkAndRefresh(): Promise { + const state = get(authStore) + + console.log('[AuthStore] checkAndRefresh - current state:', { + hasToken: !!state.accessToken, + isAuthenticated: state.isAuthenticated, + expiresAt: state.expiresAt?.toISOString() + }) + + if (!state.accessToken) { + console.warn('[AuthStore] checkAndRefresh - no access token') + return null + } + + // Check if we need to refresh (within 5 minutes of expiry) + if (state.expiresAt) { + const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000) + + if (fiveMinutesFromNow >= state.expiresAt) { + const refreshed = await this.refresh() + if (refreshed) { + return get(authStore).accessToken + } + } + } + + return state.accessToken + }, + + initFromServer: (accessToken: string | null, user: UserInfo | null, expiresAt: string | null) => { + console.log('[AuthStore] initFromServer called with:', { + hasToken: !!accessToken, + hasUser: !!user, + expiresAt + }) + if (accessToken && user && expiresAt) { + set({ + accessToken, + refreshToken: null, + user, + expiresAt: new Date(expiresAt), + isRefreshing: false, + isAuthenticated: true + }) + } else { + set({ + accessToken: null, + refreshToken: null, + user: null, + expiresAt: null, + isRefreshing: false, + isAuthenticated: false + }) + } + } + } +} + +export const authStore = createAuthStore() \ No newline at end of file diff --git a/src/lib/types/AccountCookie.d.ts b/src/lib/types/AccountCookie.d.ts index c2d19ee7..82836769 100644 --- a/src/lib/types/AccountCookie.d.ts +++ b/src/lib/types/AccountCookie.d.ts @@ -3,4 +3,5 @@ export interface AccountCookie { username: string token: string role: number + expires_at?: string // ISO string of when the token expires } diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 7152a145..17c002ef 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -12,9 +12,20 @@ export const load: LayoutServerLoad = async ({ locals }) => { const currentUser = locals.session.user ?? null const isAuthenticated = locals.session.isAuthenticated + // Debug logging for auth data + if (locals.auth) { + console.log('[+layout.server] Auth data being passed to client:', { + hasToken: !!locals.auth.accessToken, + hasUser: !!locals.auth.user, + hasExpiresAt: !!locals.auth.expiresAt + }) + } + return { isAuthenticated, account, - currentUser + currentUser, + // Pass auth data for client-side store initialization + auth: locals.auth } } diff --git a/src/routes/auth/login/+server.ts b/src/routes/auth/login/+server.ts index 17dc9120..3bf47e14 100644 --- a/src/routes/auth/login/+server.ts +++ b/src/routes/auth/login/+server.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from '@sveltejs/kit' import { json } from '@sveltejs/kit' import { z } from 'zod' import { passwordGrantLogin } from '$lib/auth/oauth' -import { users } from '$lib/api/resources/users' +import { UserAdapter } from '$lib/api/adapters' import { buildCookies } from '$lib/auth/map' import { setAccountCookie, setUserCookie, setRefreshCookie } from '$lib/auth/cookies' @@ -23,7 +23,9 @@ export const POST: RequestHandler = async ({ request, cookies, url, fetch }) => try { const oauth = await passwordGrantLogin(fetch, parsed.data) - const info = await users.info(fetch, oauth.user.username, { + // Create a UserAdapter instance and pass the auth token + const userAdapter = new UserAdapter() + const info = await userAdapter.getInfo(oauth.user.username, { headers: { Authorization: `Bearer ${oauth.access_token}` } @@ -36,7 +38,14 @@ export const POST: RequestHandler = async ({ request, cookies, url, fetch }) => setUserCookie(cookies, user, { secure, expires: accessTokenExpiresAt }) setRefreshCookie(cookies, refresh, { secure, expires: accessTokenExpiresAt }) - return json({ success: true, user: { username: info.username, avatar: info.avatar } }) + // Return access token for client-side storage + return json({ + success: true, + user: { username: info.username, avatar: info.avatar }, + access_token: oauth.access_token, + expires_in: oauth.expires_in, + expires_at: accessTokenExpiresAt.toISOString() + }) } catch (e: any) { if (String(e?.message) === 'unauthorized') { return json({ error: 'Invalid email or password' }, { status: 401 }) diff --git a/src/routes/auth/refresh/+server.ts b/src/routes/auth/refresh/+server.ts index 9367c58c..0ae1ffb0 100644 --- a/src/routes/auth/refresh/+server.ts +++ b/src/routes/auth/refresh/+server.ts @@ -68,6 +68,13 @@ export const POST: RequestHandler = async ({ cookies, fetch, url }) => { return json({ success: true, username: data.user.username, - expires_at: accessTokenExpiresAt.toISOString() + access_token: data.access_token, + expires_in: data.expires_in, + expires_at: accessTokenExpiresAt.toISOString(), + user: { + id: data.user.id, + username: data.user.username, + role: data.user.role + } }) }