add auth store and update auth flow
This commit is contained in:
parent
838e09d17b
commit
d6b868a9fd
8 changed files with 236 additions and 6 deletions
5
src/app.d.ts
vendored
5
src/app.d.ts
vendored
|
|
@ -9,6 +9,11 @@ declare global {
|
|||
user: UserCookie | null
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
auth: {
|
||||
accessToken: string
|
||||
user: UserCookie | null
|
||||
expiresAt: string
|
||||
} | null
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
178
src/lib/stores/auth.store.ts
Normal file
178
src/lib/stores/auth.store.ts
Normal 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()
|
||||
1
src/lib/types/AccountCookie.d.ts
vendored
1
src/lib/types/AccountCookie.d.ts
vendored
|
|
@ -3,4 +3,5 @@ export interface AccountCookie {
|
|||
username: string
|
||||
token: string
|
||||
role: number
|
||||
expires_at?: string // ISO string of when the token expires
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue