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
|
user: UserCookie | null
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
}
|
}
|
||||||
|
auth: {
|
||||||
|
accessToken: string
|
||||||
|
user: UserCookie | null
|
||||||
|
expiresAt: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,30 @@ export const handleSession: Handle = async ({ event, resolve }) => {
|
||||||
const account = getAccountFromCookies(event.cookies)
|
const account = getAccountFromCookies(event.cookies)
|
||||||
const user = getUserFromCookies(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 = {
|
event.locals.session = {
|
||||||
account,
|
account,
|
||||||
user,
|
user,
|
||||||
isAuthenticated: Boolean(account?.token)
|
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)
|
return resolve(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ export function buildCookies(oauth: OAuthLoginResponse, info: UserInfoResponse)
|
||||||
userId: info.id,
|
userId: info.id,
|
||||||
username: info.username,
|
username: info.username,
|
||||||
token: oauth.access_token,
|
token: oauth.access_token,
|
||||||
role: info.role
|
role: info.role,
|
||||||
|
expires_at: accessTokenExpiresAt.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: UserCookie = {
|
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
|
username: string
|
||||||
token: string
|
token: string
|
||||||
role: number
|
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 currentUser = locals.session.user ?? null
|
||||||
const isAuthenticated = locals.session.isAuthenticated
|
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 {
|
return {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
account,
|
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 { json } from '@sveltejs/kit'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { passwordGrantLogin } from '$lib/auth/oauth'
|
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 { buildCookies } from '$lib/auth/map'
|
||||||
import { setAccountCookie, setUserCookie, setRefreshCookie } from '$lib/auth/cookies'
|
import { setAccountCookie, setUserCookie, setRefreshCookie } from '$lib/auth/cookies'
|
||||||
|
|
||||||
|
|
@ -23,7 +23,9 @@ export const POST: RequestHandler = async ({ request, cookies, url, fetch }) =>
|
||||||
try {
|
try {
|
||||||
const oauth = await passwordGrantLogin(fetch, parsed.data)
|
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: {
|
headers: {
|
||||||
Authorization: `Bearer ${oauth.access_token}`
|
Authorization: `Bearer ${oauth.access_token}`
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +38,14 @@ export const POST: RequestHandler = async ({ request, cookies, url, fetch }) =>
|
||||||
setUserCookie(cookies, user, { secure, expires: accessTokenExpiresAt })
|
setUserCookie(cookies, user, { secure, expires: accessTokenExpiresAt })
|
||||||
setRefreshCookie(cookies, refresh, { 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) {
|
} catch (e: any) {
|
||||||
if (String(e?.message) === 'unauthorized') {
|
if (String(e?.message) === 'unauthorized') {
|
||||||
return json({ error: 'Invalid email or password' }, { status: 401 })
|
return json({ error: 'Invalid email or password' }, { status: 401 })
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,13 @@ export const POST: RequestHandler = async ({ cookies, fetch, url }) => {
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
username: data.user.username,
|
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