auth fixes
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Repository, RepositoryConnection, RepositoryConnectionCreate, RepositoryCreate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
||||
import { getToken, clearToken } from '../auth/oidc'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
@@ -13,8 +14,49 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem('access_token')
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
async function refreshToken(): Promise<string | null> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
|
||||
const response = await fetch(`${API_URL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
return data.access_token
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
clearToken()
|
||||
return null
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
async function fetchWithAuth(
|
||||
@@ -22,7 +64,7 @@ async function fetchWithAuth(
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const url = `${API_URL}/api/v1${endpoint}`
|
||||
const token = getAuthToken()
|
||||
const token = getToken()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -46,6 +88,22 @@ async function fetchWithAuth(
|
||||
console.log(`[API] ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
if (response.status === 401 && token) {
|
||||
const newToken = await refreshToken()
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`
|
||||
const retryResponse = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
if (!retryResponse.ok) {
|
||||
const data = await retryResponse.json().catch(() => undefined)
|
||||
throw new ApiError(retryResponse.status, retryResponse.statusText, data)
|
||||
}
|
||||
return retryResponse
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => undefined)
|
||||
throw new ApiError(response.status, response.statusText, data)
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../api/client'
|
||||
import { getToken, clearToken } from './oidc'
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { setUser, setError, setState } = useAuthStore()
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
setState('unauthenticated')
|
||||
if (isMounted.current) {
|
||||
setState('unauthenticated')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const user = await api.getCurrentUser()
|
||||
setUser(user)
|
||||
|
||||
if (isMounted.current) {
|
||||
setUser(user)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization failed:', error)
|
||||
setError(error instanceof Error ? error : new Error('Auth failed'))
|
||||
localStorage.removeItem('access_token')
|
||||
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
clearToken()
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
setError(error instanceof Error ? error : new Error('Auth failed'))
|
||||
clearToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// PKCE utilities for OIDC flow
|
||||
function generateRandomString(length: number): string {
|
||||
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let text = ''
|
||||
@@ -34,6 +33,18 @@ export function clearPKCE(): void {
|
||||
sessionStorage.removeItem('pkce_verifier')
|
||||
}
|
||||
|
||||
export function storeState(state: string): void {
|
||||
sessionStorage.setItem('oidc_state', state)
|
||||
}
|
||||
|
||||
export function getState(): string | null {
|
||||
return sessionStorage.getItem('oidc_state')
|
||||
}
|
||||
|
||||
export function clearState(): void {
|
||||
sessionStorage.removeItem('oidc_state')
|
||||
}
|
||||
|
||||
export function storeToken(token: string): void {
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getPKCE, clearPKCE, storeToken } from '../auth/oidc'
|
||||
import { getPKCE, clearPKCE, storeToken, getState, clearState } from '../auth/oidc'
|
||||
import { api } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER
|
||||
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
|
||||
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
|
||||
|
||||
@@ -12,6 +11,13 @@ export default function CallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setUser, setError } = useAuthStore()
|
||||
const [status, setStatus] = useState('Processing authentication...')
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
@@ -19,6 +25,8 @@ export default function CallbackPage() {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const error = urlParams.get('error')
|
||||
const state = urlParams.get('state')
|
||||
const storedState = getState()
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Authentication error: ${error}`)
|
||||
@@ -28,12 +36,18 @@ export default function CallbackPage() {
|
||||
throw new Error('No authorization code received')
|
||||
}
|
||||
|
||||
if (!state || state !== storedState) {
|
||||
throw new Error('Invalid or missing state parameter')
|
||||
}
|
||||
|
||||
const verifier = getPKCE()
|
||||
if (!verifier) {
|
||||
throw new Error('PKCE verifier not found')
|
||||
}
|
||||
|
||||
setStatus('Exchanging code for token...')
|
||||
if (isMounted.current) {
|
||||
setStatus('Exchanging code for token...')
|
||||
}
|
||||
|
||||
const tokenEndpoint = 'https://auth.commumedia.org/application/o/token/'
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
@@ -51,23 +65,35 @@ export default function CallbackPage() {
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error('Token exchange failed')
|
||||
const errorData = await tokenResponse.json().catch(() => ({}))
|
||||
throw new Error(errorData.error_description || errorData.error || 'Token exchange failed')
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
storeToken(tokenData.access_token)
|
||||
clearPKCE()
|
||||
clearState()
|
||||
|
||||
if (isMounted.current) {
|
||||
setStatus('Fetching user information...')
|
||||
}
|
||||
|
||||
setStatus('Fetching user information...')
|
||||
const user = await api.getCurrentUser()
|
||||
setUser(user)
|
||||
|
||||
navigate('/')
|
||||
if (isMounted.current) {
|
||||
setUser(user)
|
||||
window.location.href = '/'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error)
|
||||
setError(error instanceof Error ? error : new Error('Authentication failed'))
|
||||
setStatus('Authentication failed')
|
||||
setTimeout(() => navigate('/login'), 3000)
|
||||
clearPKCE()
|
||||
clearState()
|
||||
|
||||
if (isMounted.current) {
|
||||
setError(error instanceof Error ? error : new Error('Authentication failed'))
|
||||
setStatus('Authentication failed')
|
||||
setTimeout(() => navigate('/login'), 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPKCE, storePKCE } from '../auth/oidc'
|
||||
import { createPKCE, storePKCE, storeState } from '../auth/oidc'
|
||||
|
||||
const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER
|
||||
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
|
||||
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
|
||||
|
||||
function generateState(): string {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
const initiateLogin = async () => {
|
||||
const { verifier, challenge } = await createPKCE()
|
||||
storePKCE(verifier)
|
||||
|
||||
const state = generateState()
|
||||
storeState(state)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
@@ -18,6 +26,7 @@ export default function LoginPage() {
|
||||
scope: 'openid profile email',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
})
|
||||
|
||||
const authorizationEndpoint = 'https://auth.commumedia.org/application/o/authorize/'
|
||||
|
||||
@@ -20,5 +20,8 @@ export const useAuthStore = create<AuthStore>((set) => ({
|
||||
setState: (state) => set({ state }),
|
||||
setUser: (user) => set({ user, state: user ? 'authenticated' : 'unauthenticated' }),
|
||||
setError: (error) => set({ error, state: 'error' }),
|
||||
logout: () => set({ user: null, state: 'unauthenticated', error: null }),
|
||||
logout: () => {
|
||||
localStorage.removeItem('access_token')
|
||||
set({ user: null, state: 'unauthenticated', error: null })
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user