Initial commit
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OAuth2 Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1729
frontend/package-lock.json
generated
Normal file
1729
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "oauth-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
23
frontend/src/App.jsx
Normal file
23
frontend/src/App.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import CallbackPage from './pages/CallbackPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<LoginPage />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
12
frontend/src/components/ProtectedRoute.jsx
Normal file
12
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { accessToken } = useAuth()
|
||||
|
||||
if (!accessToken) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
17
frontend/src/config.js
Normal file
17
frontend/src/config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// OAuth2 / backend configuration
|
||||
//
|
||||
// AUTHORIZE_URL uses a relative path so it works in both environments:
|
||||
// - Dev: Vite proxies /o → http://localhost:8000/o
|
||||
// - Prod: nginx routes /o → Django directly (same origin, no proxy needed)
|
||||
//
|
||||
// REDIRECT_URI derives from the current page's origin at runtime, so it
|
||||
// automatically resolves to the correct host in dev and production.
|
||||
|
||||
export const CLIENT_ID = 'react-oauth-client'
|
||||
export const REDIRECT_URI = `${window.location.origin}/callback`
|
||||
export const SCOPES = 'read'
|
||||
|
||||
export const AUTHORIZE_URL = '/o/authorize/'
|
||||
export const TOKEN_URL = '/o/token/'
|
||||
export const REVOKE_URL = '/o/revoke_token/'
|
||||
export const API_BASE = '/api'
|
||||
33
frontend/src/context/AuthContext.jsx
Normal file
33
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [accessToken, setAccessToken] = useState(
|
||||
() => sessionStorage.getItem('access_token') ?? null
|
||||
)
|
||||
|
||||
const setToken = useCallback((token) => {
|
||||
sessionStorage.setItem('access_token', token)
|
||||
setAccessToken(token)
|
||||
}, [])
|
||||
|
||||
const clearToken = useCallback(() => {
|
||||
sessionStorage.removeItem('access_token')
|
||||
sessionStorage.removeItem('pkce_verifier')
|
||||
sessionStorage.removeItem('oauth_state')
|
||||
setAccessToken(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ accessToken, setToken, clearToken }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: #1a1a2e;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
90
frontend/src/pages/CallbackPage.jsx
Normal file
90
frontend/src/pages/CallbackPage.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { CLIENT_ID, REDIRECT_URI, TOKEN_URL } from '../config'
|
||||
import styles from './CallbackPage.module.css'
|
||||
|
||||
export default function CallbackPage() {
|
||||
const { setToken } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const called = useRef(false) // prevent double-invocation in StrictMode
|
||||
|
||||
useEffect(() => {
|
||||
if (called.current) return
|
||||
called.current = true
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get('code')
|
||||
const returnedState = params.get('state')
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description')
|
||||
|
||||
if (error) {
|
||||
console.error('OAuth error:', error, errorDesc)
|
||||
navigate(`/?error=${encodeURIComponent(errorDesc ?? error)}`, { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
navigate('/?error=missing_code', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const savedState = sessionStorage.getItem('oauth_state')
|
||||
if (returnedState !== savedState) {
|
||||
console.error('State mismatch — possible CSRF attack')
|
||||
navigate('/?error=state_mismatch', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const verifier = sessionStorage.getItem('pkce_verifier')
|
||||
if (!verifier) {
|
||||
navigate('/?error=missing_verifier', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
exchangeCode(code, verifier)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function exchangeCode(code, verifier) {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: verifier,
|
||||
})
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.error_description ?? err.error ?? 'Token exchange failed')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setToken(data.access_token)
|
||||
|
||||
// Clean up PKCE state
|
||||
sessionStorage.removeItem('pkce_verifier')
|
||||
sessionStorage.removeItem('oauth_state')
|
||||
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (err) {
|
||||
console.error('Token exchange error:', err)
|
||||
navigate(`/?error=${encodeURIComponent(err.message)}`, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.spinner} />
|
||||
<p className={styles.message}>Completing sign-in…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
frontend/src/pages/CallbackPage.module.css
Normal file
26
frontend/src/pages/CallbackPage.module.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
242
frontend/src/pages/DashboardPage.jsx
Normal file
242
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { API_BASE, REVOKE_URL, CLIENT_ID, REDIRECT_URI, SCOPES, AUTHORIZE_URL } from '../config'
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce'
|
||||
import {
|
||||
isWebAuthnSupported,
|
||||
prepareRegistrationOptions,
|
||||
serializeRegistrationCredential,
|
||||
} from '../utils/webauthn'
|
||||
import styles from './DashboardPage.module.css'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { accessToken, clearToken } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [user, setUser] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loggingOut, setLoggingOut] = useState(false)
|
||||
const [authUrl, setAuthUrl] = useState(null)
|
||||
|
||||
const [touchIdStatus, setTouchIdStatus] = useState(null) // 'registering' | 'success' | 'error'
|
||||
const [touchIdMessage, setTouchIdMessage] = useState('')
|
||||
|
||||
const webAuthnAvailable = isWebAuthnSupported()
|
||||
|
||||
useEffect(() => {
|
||||
async function buildAuthUrl() {
|
||||
const verifier = generateCodeVerifier()
|
||||
const challenge = await generateCodeChallenge(verifier)
|
||||
const state = generateState()
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
setAuthUrl(`${AUTHORIZE_URL}?${params}`)
|
||||
}
|
||||
buildAuthUrl()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser()
|
||||
}, [accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchUser() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/me/`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUser(data)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
setLoggingOut(true)
|
||||
try {
|
||||
await fetch(REVOKE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ token: accessToken, client_id: CLIENT_ID }),
|
||||
})
|
||||
} catch {
|
||||
// Ignore revocation errors — clear locally regardless
|
||||
} finally {
|
||||
clearToken()
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegisterTouchId() {
|
||||
setTouchIdStatus('registering')
|
||||
setTouchIdMessage('')
|
||||
|
||||
try {
|
||||
// 1. Get registration options from server
|
||||
const beginRes = await fetch(`${API_BASE}/webauthn/register/begin/`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!beginRes.ok) {
|
||||
const data = await beginRes.json()
|
||||
throw new Error(data.detail || 'Failed to start registration.')
|
||||
}
|
||||
const options = await beginRes.json()
|
||||
|
||||
// 2. Ask browser / authenticator (Touch ID prompt)
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: prepareRegistrationOptions(options),
|
||||
})
|
||||
|
||||
// 3. Send attestation to server
|
||||
const completeRes = await fetch(`${API_BASE}/webauthn/register/complete/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(serializeRegistrationCredential(credential)),
|
||||
})
|
||||
const completeData = await completeRes.json()
|
||||
if (!completeRes.ok) throw new Error(completeData.detail || 'Registration failed.')
|
||||
|
||||
setTouchIdStatus('success')
|
||||
setTouchIdMessage('Touch ID registered! You can now sign in with Touch ID.')
|
||||
// Refresh user to update has_touch_id flag
|
||||
fetchUser()
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setTouchIdStatus('error')
|
||||
setTouchIdMessage('Biometric prompt was dismissed.')
|
||||
} else {
|
||||
setTouchIdStatus('error')
|
||||
setTouchIdMessage(err.message || 'Registration failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerInner}>
|
||||
<span className={styles.headerLogo}>🔐 OAuth2 Demo</span>
|
||||
<button
|
||||
className={styles.logoutBtn}
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
>
|
||||
{loggingOut ? 'Signing out…' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>Dashboard</h1>
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorBanner}>
|
||||
Failed to load user info: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!user && !error && (
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.avatar}>
|
||||
{user.username?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<h2 className={styles.username}>{user.username}</h2>
|
||||
{user.email && <p className={styles.email}>{user.email}</p>}
|
||||
|
||||
<dl className={styles.details}>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>User ID</dt>
|
||||
<dd>{user.id}</dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>First name</dt>
|
||||
<dd>{user.first_name || '—'}</dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Last name</dt>
|
||||
<dd>{user.last_name || '—'}</dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Member since</dt>
|
||||
<dd>{new Date(user.date_joined).toLocaleDateString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className={styles.tokenInfo}>
|
||||
<p className={styles.tokenLabel}>Access Token (truncated)</p>
|
||||
<code className={styles.tokenValue}>
|
||||
{accessToken.slice(0, 20)}…
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* ── Touch ID section ─────────────────────────────────── */}
|
||||
{webAuthnAvailable && (
|
||||
<div className={styles.touchIdSection}>
|
||||
<p className={styles.touchIdLabel}>Touch ID / Biometric Login</p>
|
||||
{user.has_touch_id ? (
|
||||
<p className={styles.touchIdRegistered}>
|
||||
Touch ID is registered for this account.
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
className={styles.touchIdBtn}
|
||||
onClick={handleRegisterTouchId}
|
||||
disabled={touchIdStatus === 'registering'}
|
||||
>
|
||||
{touchIdStatus === 'registering'
|
||||
? 'Waiting for biometric…'
|
||||
: user.has_touch_id
|
||||
? 'Register another Touch ID'
|
||||
: 'Register Touch ID'}
|
||||
</button>
|
||||
{touchIdStatus === 'success' && (
|
||||
<p className={styles.touchIdSuccess}>{touchIdMessage}</p>
|
||||
)}
|
||||
{touchIdStatus === 'error' && (
|
||||
<p className={styles.touchIdError}>{touchIdMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUrl && (
|
||||
<div className={styles.qrSection}>
|
||||
<p className={styles.qrLabel}>Authorize on another device</p>
|
||||
<div className={styles.qrWrapper}>
|
||||
<QRCodeSVG
|
||||
value={authUrl}
|
||||
size={160}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#1a1a2e"
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.qrHint}>
|
||||
Scan to open the authorization server
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
frontend/src/pages/DashboardPage.module.css
Normal file
275
frontend/src/pages/DashboardPage.module.css
Normal file
@@ -0,0 +1,275 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||||
.header {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.headerLogo {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
padding: 0.4rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
color: #374151;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.logoutBtn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.logoutBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Main ─────────────────────────────────────────────────────────────────── */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2.5rem 1.5rem;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* ── Card ─────────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.07);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.email {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Details table ────────────────────────────────────────────────────────── */
|
||||
.details {
|
||||
text-align: left;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detailRow {
|
||||
display: flex;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.detailRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detailRow dt {
|
||||
width: 130px;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailRow dd {
|
||||
font-size: 0.9rem;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* ── Token info ───────────────────────────────────────────────────────────── */
|
||||
.tokenInfo {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tokenLabel {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tokenValue {
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #4f46e5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Touch ID ─────────────────────────────────────────────────────────────── */
|
||||
.touchIdSection {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.touchIdLabel {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.touchIdRegistered {
|
||||
font-size: 0.85rem;
|
||||
color: #16a34a;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.touchIdBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: #ffffff;
|
||||
color: #1a1a2e;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.touchIdBtn:hover {
|
||||
border-color: #4f46e5;
|
||||
background: #f5f4ff;
|
||||
}
|
||||
|
||||
.touchIdBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.touchIdSuccess {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.touchIdError {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── QR Code ──────────────────────────────────────────────────────────────── */
|
||||
.qrSection {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrLabel {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qrWrapper {
|
||||
display: inline-flex;
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.qrHint {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── States ───────────────────────────────────────────────────────────────── */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
199
frontend/src/pages/LoginPage.jsx
Normal file
199
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce'
|
||||
import {
|
||||
isWebAuthnSupported,
|
||||
prepareAuthenticationOptions,
|
||||
serializeAuthenticationCredential,
|
||||
} from '../utils/webauthn'
|
||||
import { CLIENT_ID, REDIRECT_URI, SCOPES, AUTHORIZE_URL } from '../config'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { accessToken } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [touchIdUsername, setTouchIdUsername] = useState('')
|
||||
const [touchIdLoading, setTouchIdLoading] = useState(false)
|
||||
const [touchIdError, setTouchIdError] = useState(null)
|
||||
|
||||
const webAuthnAvailable = isWebAuthnSupported()
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) navigate('/dashboard', { replace: true })
|
||||
}, [accessToken, navigate])
|
||||
|
||||
/** Shared helper: once credentials are confirmed, redirect into the OAuth PKCE flow. */
|
||||
async function redirectToOAuth() {
|
||||
const verifier = generateCodeVerifier()
|
||||
const challenge = await generateCodeChallenge(verifier)
|
||||
const state = generateState()
|
||||
|
||||
sessionStorage.setItem('pkce_verifier', verifier)
|
||||
sessionStorage.setItem('oauth_state', state)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
|
||||
window.location.href = `${AUTHORIZE_URL}?${params}`
|
||||
}
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.detail || 'Login failed.')
|
||||
return
|
||||
}
|
||||
|
||||
await redirectToOAuth()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTouchIdLogin(e) {
|
||||
e.preventDefault()
|
||||
setTouchIdError(null)
|
||||
setTouchIdLoading(true)
|
||||
|
||||
try {
|
||||
// 1. Get authentication options from server
|
||||
const beginRes = await fetch('/api/webauthn/auth/begin/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: touchIdUsername }),
|
||||
})
|
||||
if (!beginRes.ok) {
|
||||
const data = await beginRes.json()
|
||||
throw new Error(data.detail || 'Touch ID not available for this account.')
|
||||
}
|
||||
const options = await beginRes.json()
|
||||
|
||||
// 2. Trigger biometric prompt
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: prepareAuthenticationOptions(options),
|
||||
})
|
||||
|
||||
// 3. Send assertion to server
|
||||
const completeRes = await fetch('/api/webauthn/auth/complete/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: touchIdUsername,
|
||||
...serializeAuthenticationCredential(credential),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const data = await completeRes.json()
|
||||
throw new Error(data.detail || 'Touch ID verification failed.')
|
||||
}
|
||||
|
||||
// 4. Identity confirmed — proceed to OAuth PKCE flow
|
||||
await redirectToOAuth()
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setTouchIdError('Biometric prompt was dismissed or timed out.')
|
||||
} else {
|
||||
setTouchIdError(err.message || 'Touch ID sign-in failed.')
|
||||
}
|
||||
} finally {
|
||||
setTouchIdLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.logo}>🔐</div>
|
||||
<h1 className={styles.title}>OAuth2 Demo</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Sign in with your credentials to authorize access.
|
||||
</p>
|
||||
|
||||
{/* ── Password login ─────────────────────────────────────── */}
|
||||
<form onSubmit={handleLogin} className={styles.form}>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<button className={styles.loginBtn} type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* ── Touch ID login (shown only when WebAuthn is supported) ─ */}
|
||||
{webAuthnAvailable && (
|
||||
<>
|
||||
<div className={styles.divider}>
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleTouchIdLogin} className={styles.form}>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={touchIdUsername}
|
||||
onChange={e => setTouchIdUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username webauthn"
|
||||
/>
|
||||
{touchIdError && <p className={styles.error}>{touchIdError}</p>}
|
||||
<button
|
||||
className={styles.touchIdBtn}
|
||||
type="submit"
|
||||
disabled={touchIdLoading}
|
||||
>
|
||||
{touchIdLoading ? 'Waiting for biometric…' : 'Sign in with Touch ID'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className={styles.hint}>
|
||||
Demo credentials: <strong>admin</strong> / <strong>admin123</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
frontend/src/pages/LoginPage.module.css
Normal file
193
frontend/src/pages/LoginPage.module.css
Normal file
@@ -0,0 +1,193 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.1);
|
||||
padding: 3rem 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loginBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loginBtn:hover {
|
||||
background: #4338ca;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.loginBtn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.loginBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.hint strong {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.touchIdBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: #ffffff;
|
||||
color: #1a1a2e;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s, transform 0.1s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.touchIdBtn::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234f46e5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.touchIdBtn:hover {
|
||||
border-color: #4f46e5;
|
||||
background: #f5f4ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.touchIdBtn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.touchIdBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.qrWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.75rem;
|
||||
min-height: 212px;
|
||||
}
|
||||
|
||||
.qrPlaceholder {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.qrLabel {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
43
frontend/src/utils/pkce.js
Normal file
43
frontend/src/utils/pkce.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) utilities.
|
||||
* Uses the Web Crypto API — no external dependencies needed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random code verifier (43-128 chars, base64url).
|
||||
*/
|
||||
export function generateCodeVerifier() {
|
||||
const array = new Uint8Array(48) // 48 bytes → 64-char base64url string
|
||||
crypto.getRandomValues(array)
|
||||
return base64urlEncode(array)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the code challenge from the verifier (S256 method).
|
||||
* @param {string} verifier
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier) {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return base64urlEncode(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random state parameter to prevent CSRF.
|
||||
*/
|
||||
export function generateState() {
|
||||
const array = new Uint8Array(16)
|
||||
crypto.getRandomValues(array)
|
||||
return base64urlEncode(array)
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function base64urlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...buffer))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
94
frontend/src/utils/webauthn.js
Normal file
94
frontend/src/utils/webauthn.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/** Returns true when the browser supports the WebAuthn API. */
|
||||
export function isWebAuthnSupported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.PublicKeyCredential === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
/** Decode a base64url string to an ArrayBuffer. */
|
||||
export function base64urlToBuffer(base64url) {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=')
|
||||
const binary = atob(padded)
|
||||
const buffer = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i)
|
||||
return buffer.buffer
|
||||
}
|
||||
|
||||
/** Encode an ArrayBuffer (or Uint8Array) as a base64url string. */
|
||||
export function bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert server-provided registration options so the browser API can consume them.
|
||||
* The server sends base64url strings; the browser expects ArrayBuffers.
|
||||
*/
|
||||
export function prepareRegistrationOptions(options) {
|
||||
return {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64urlToBuffer(options.user.id),
|
||||
},
|
||||
excludeCredentials: (options.excludeCredentials ?? []).map(c => ({
|
||||
...c,
|
||||
id: base64urlToBuffer(c.id),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert server-provided authentication options so the browser API can consume them.
|
||||
*/
|
||||
export function prepareAuthenticationOptions(options) {
|
||||
return {
|
||||
...options,
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
allowCredentials: (options.allowCredentials ?? []).map(c => ({
|
||||
...c,
|
||||
id: base64urlToBuffer(c.id),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the PublicKeyCredential returned by navigator.credentials.create()
|
||||
* into a plain JSON object the server can parse.
|
||||
*/
|
||||
export function serializeRegistrationCredential(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
attestationObject: bufferToBase64url(credential.response.attestationObject),
|
||||
},
|
||||
type: credential.type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the PublicKeyCredential returned by navigator.credentials.get()
|
||||
* into a plain JSON object the server can parse.
|
||||
*/
|
||||
export function serializeAuthenticationCredential(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
|
||||
signature: bufferToBase64url(credential.response.signature),
|
||||
userHandle: credential.response.userHandle
|
||||
? bufferToBase64url(credential.response.userHandle)
|
||||
: null,
|
||||
},
|
||||
type: credential.type,
|
||||
}
|
||||
}
|
||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Proxy /api calls to Django — avoids CORS issues for API requests
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// Proxy /o (OAuth endpoints) so token exchange POST avoids CORS preflight issues
|
||||
'/o': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user