mirror of
https://github.com/mruwnik/memory.git
synced 2025-07-30 06:36:07 +02:00
191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
import { useState, useCallback } from 'react'
|
|
|
|
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:8000'
|
|
const SESSION_COOKIE_NAME = import.meta.env.VITE_SESSION_COOKIE_NAME || 'session_id'
|
|
const REDIRECT_URI = `${window.location.origin}/ui`
|
|
|
|
// OAuth utilities
|
|
const generateCodeVerifier = () => {
|
|
const array = new Uint8Array(32)
|
|
crypto.getRandomValues(array)
|
|
return btoa(String.fromCharCode(...array))
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '')
|
|
}
|
|
|
|
const generateCodeChallenge = async (verifier: string) => {
|
|
const data = new TextEncoder().encode(verifier)
|
|
const digest = await crypto.subtle.digest('SHA-256', data)
|
|
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '')
|
|
}
|
|
|
|
const generateState = () => {
|
|
const array = new Uint8Array(16)
|
|
crypto.getRandomValues(array)
|
|
return btoa(String.fromCharCode(...array))
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '')
|
|
}
|
|
|
|
// Storage utilities
|
|
const setCookie = (name: string, value: string, days = 30) => {
|
|
const expires = new Date()
|
|
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
|
|
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`
|
|
}
|
|
|
|
const getClientId = () => localStorage.getItem('oauth_client_id')
|
|
const setClientId = (clientId) => localStorage.setItem('oauth_client_id', clientId)
|
|
|
|
export const useOAuth = () => {
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Register OAuth client with the server
|
|
const registerClient = useCallback(async () => {
|
|
try {
|
|
const response = await fetch(`${SERVER_URL}/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
client_name: 'React Memory App',
|
|
redirect_uris: [REDIRECT_URI],
|
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
response_types: ['code'],
|
|
scope: 'read write',
|
|
token_endpoint_auth_method: 'none'
|
|
})
|
|
})
|
|
|
|
if (response.ok) {
|
|
const clientInfo = await response.json()
|
|
setClientId(clientInfo.client_id)
|
|
return clientInfo.client_id
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
console.error('Error registering client:', error)
|
|
return null
|
|
}
|
|
}, [])
|
|
|
|
// Start OAuth authorization flow
|
|
const startOAuth = useCallback(async () => {
|
|
setError(null)
|
|
|
|
let clientId = getClientId()
|
|
if (!clientId) {
|
|
clientId = await registerClient()
|
|
if (!clientId) {
|
|
setError('Failed to register OAuth client')
|
|
return
|
|
}
|
|
}
|
|
|
|
const state = generateState()
|
|
const codeVerifier = generateCodeVerifier()
|
|
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
|
|
|
// Store for callback verification
|
|
localStorage.setItem('oauth_state', state)
|
|
localStorage.setItem('code_verifier', codeVerifier)
|
|
|
|
// Build authorization URL
|
|
const authUrl = new URL(`${SERVER_URL}/authorize`)
|
|
authUrl.searchParams.set('response_type', 'code')
|
|
authUrl.searchParams.set('client_id', clientId)
|
|
authUrl.searchParams.set('redirect_uri', REDIRECT_URI)
|
|
authUrl.searchParams.set('scope', 'read write')
|
|
authUrl.searchParams.set('state', state)
|
|
authUrl.searchParams.set('code_challenge', codeChallenge)
|
|
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
|
|
window.location.href = authUrl.toString()
|
|
}, [registerClient])
|
|
|
|
// Handle OAuth callback
|
|
const handleCallback = useCallback(async () => {
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const code = urlParams.get('code')
|
|
const state = urlParams.get('state')
|
|
const error = urlParams.get('error')
|
|
|
|
if (error) {
|
|
setError(`OAuth error: ${error}`)
|
|
return false
|
|
}
|
|
|
|
if (!code || !state) return false
|
|
|
|
// Verify state
|
|
const storedState = localStorage.getItem('oauth_state')
|
|
const storedCodeVerifier = localStorage.getItem('code_verifier')
|
|
const clientId = getClientId()
|
|
|
|
if (state !== storedState) {
|
|
setError('Invalid state parameter')
|
|
return false
|
|
}
|
|
|
|
if (!clientId) {
|
|
setError('Client ID not found')
|
|
return false
|
|
}
|
|
|
|
try {
|
|
// Exchange code for tokens
|
|
const response = await fetch(`${SERVER_URL}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: clientId,
|
|
code,
|
|
redirect_uri: REDIRECT_URI,
|
|
code_verifier: storedCodeVerifier || undefined
|
|
})
|
|
})
|
|
|
|
if (response.ok) {
|
|
const tokens = await response.json()
|
|
|
|
// Store tokens
|
|
setCookie('access_token', tokens.access_token, 30)
|
|
setCookie(SESSION_COOKIE_NAME, tokens.access_token, 30)
|
|
if (tokens.refresh_token) {
|
|
setCookie('refresh_token', tokens.refresh_token, 30)
|
|
}
|
|
|
|
// Cleanup
|
|
localStorage.removeItem('oauth_state')
|
|
localStorage.removeItem('code_verifier')
|
|
window.history.replaceState({}, document.title, window.location.pathname)
|
|
|
|
return true
|
|
} else {
|
|
const errorData = await response.json()
|
|
setError(`Token exchange failed: ${errorData.error || 'Unknown error'}`)
|
|
return false
|
|
}
|
|
} catch (err) {
|
|
setError(`Network error: ${err.message}`)
|
|
return false
|
|
}
|
|
}, [])
|
|
|
|
const clearError = useCallback(() => {
|
|
setError(null)
|
|
localStorage.removeItem('oauth_client_id') // Force re-registration on retry
|
|
}, [])
|
|
|
|
return {
|
|
error,
|
|
startOAuth,
|
|
handleCallback,
|
|
clearError
|
|
}
|
|
}
|