add auth store and update auth flow

This commit is contained in:
Justin Edmund 2025-09-23 22:09:14 -07:00
parent 838e09d17b
commit d6b868a9fd
8 changed files with 236 additions and 6 deletions

5
src/app.d.ts vendored
View file

@ -9,6 +9,11 @@ declare global {
user: UserCookie | null
isAuthenticated: boolean
}
auth: {
accessToken: string
user: UserCookie | null
expiresAt: string
} | null
}
// interface PageData {}
// interface PageState {}

View file

@ -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)
}

View file

@ -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 = {

View file

@ -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<AuthState>({
accessToken: null,
refreshToken: null,
user: null,
expiresAt: null,
isRefreshing: false,
isAuthenticated: false
})
let refreshPromise: Promise<boolean> | 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<boolean> {
// 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<string | null> {
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()

View file

@ -3,4 +3,5 @@ export interface AccountCookie {
username: string
token: string
role: number
expires_at?: string // ISO string of when the token expires
}

View file

@ -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
}
}

View file

@ -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 })

View file

@ -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
}
})
}