Initial commit

This commit is contained in:
Pat McNeely
2026-03-20 15:09:41 -04:00
commit d1f7052a58
36 changed files with 3974 additions and 0 deletions

12
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View 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
View 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>
)
}

View 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
View 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'

View 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
View 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
View 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>
)

View 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>
)
}

View 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;
}

View 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>
)
}

View 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;
}

View 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>
)
}

View 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;
}

View 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(/=+$/, '')
}

View 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
View 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,
},
},
},
})