1 parent
d53552f855
commit
2348d2e4b4
15 files changed
+412
-36
No files matched your search
@@ -69,6 +69,7 @@ export default function App() {
|
||||
<Routes>
|
||||
{/* Bare: no shell, no header, no nav. */}
|
||||
<Route path="/auth/login" element={<Login />} />
|
||||
<Route path="/auth/oidc/callback" element={<Login />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
|
||||
+2
-2
@@ -154,7 +154,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token && !headers.has('X-API-Key')) headers.set('X-API-Key', token)
|
||||
if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
@@ -369,7 +369,7 @@ export function streamLogs(handlers: LogStreamHandlers): () => void {
|
||||
if (stopped) return
|
||||
const token = getToken()
|
||||
const headers = new Headers({ Accept: 'text/event-stream' })
|
||||
if (token) headers.set('X-API-Key', token)
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
try {
|
||||
const res = await fetch(STREAM_PATH, {
|
||||
|
||||
+104
-14
@@ -7,13 +7,12 @@
|
||||
* shown as an error.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { ApiError, login, whoami } from '../lib/api'
|
||||
import type { Whoami } from '../lib/types'
|
||||
import { ApiError, login, setToken } from '../lib/api'
|
||||
import { Button, Card, CardBody, Input } from '../components/ui'
|
||||
import { Spinner } from '../components/ui'
|
||||
|
||||
@@ -22,11 +21,17 @@ function wantsPassword(mode: string): boolean {
|
||||
return mode === 'basic'
|
||||
}
|
||||
|
||||
function safeNext(value: string): string {
|
||||
return value.startsWith('/') && !value.startsWith('//') && !value.includes('\\') ? value : '/'
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [params] = useSearchParams()
|
||||
const next = params.get('next') ?? '/'
|
||||
const callbackStarted = useRef(false)
|
||||
|
||||
const [mode, setMode] = useState<string | null>(null)
|
||||
const [oidc, setOIDC] = useState<{ issuer: string; client_id: string } | null>(null)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -35,33 +40,118 @@ export default function Login() {
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
whoami()
|
||||
.then((who: Whoami) => {
|
||||
if (live) setMode(who.auth_mode ?? 'apikey')
|
||||
fetch('/api/auth/config', { headers: { Accept: 'application/json' } })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('could not load authentication settings')
|
||||
return (await res.json()) as { mode?: string; issuer?: string; client_id?: string }
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated probes and older daemons both land here: show the
|
||||
// form rather than an error, the submit will say what is wrong.
|
||||
if (live) setMode('apikey')
|
||||
.then((cfg) => {
|
||||
if (!live) return
|
||||
setMode(cfg.mode ?? 'apikey')
|
||||
if (cfg.issuer && cfg.client_id) setOIDC({ issuer: cfg.issuer, client_id: cfg.client_id })
|
||||
})
|
||||
.catch(() => { if (live) setMode('apikey') })
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const providerError = params.get('error')
|
||||
if (providerError) {
|
||||
setError(params.get('error_description') ?? `identity provider sign in failed: ${providerError}`)
|
||||
return
|
||||
}
|
||||
const code = params.get('code')
|
||||
if (!code || !oidc || mode !== 'oidc') return
|
||||
if (callbackStarted.current) return
|
||||
callbackStarted.current = true
|
||||
let pendingLogin: { state: string; verifier: string; next: string } | null
|
||||
try {
|
||||
pendingLogin = JSON.parse(sessionStorage.getItem('fancydns.oidc.pending') ?? 'null')
|
||||
} catch { pendingLogin = null }
|
||||
if (!pendingLogin?.state || pendingLogin.state !== params.get('state') || !pendingLogin.verifier) {
|
||||
setError('OIDC login state check failed. Start sign in again.')
|
||||
return
|
||||
}
|
||||
sessionStorage.removeItem('fancydns.oidc.pending')
|
||||
setPending(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`)
|
||||
if (!discoveryRes.ok) throw new Error('could not discover the identity provider')
|
||||
const discovery = (await discoveryRes.json()) as { token_endpoint?: string }
|
||||
if (!discovery.token_endpoint) throw new Error('identity provider has no token endpoint')
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: oidc.client_id,
|
||||
code,
|
||||
redirect_uri: `${window.location.origin}/auth/oidc/callback`,
|
||||
code_verifier: pendingLogin.verifier,
|
||||
})
|
||||
const tokenRes = await fetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const tokenBody = (await tokenRes.json()) as { access_token?: string; error_description?: string }
|
||||
if (!tokenRes.ok || !tokenBody.access_token) throw new Error(tokenBody.error_description ?? 'code exchange failed')
|
||||
setToken(tokenBody.access_token)
|
||||
window.location.assign(safeNext(pendingLogin.next))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'OIDC sign in failed')
|
||||
} finally { setPending(false) }
|
||||
})()
|
||||
}, [mode, oidc, params])
|
||||
|
||||
const startOIDC = async (): Promise<void> => {
|
||||
if (!oidc) return
|
||||
setPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`)
|
||||
if (!discoveryRes.ok) throw new Error('could not discover the identity provider')
|
||||
const discovery = (await discoveryRes.json()) as { authorization_endpoint?: string }
|
||||
if (!discovery.authorization_endpoint) throw new Error('identity provider has no authorization endpoint')
|
||||
const random = (bytes: number): string => {
|
||||
const data = crypto.getRandomValues(new Uint8Array(bytes))
|
||||
return btoa(String.fromCharCode(...data)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
const verifier = random(32)
|
||||
const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
||||
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(challenge))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
const state = random(24)
|
||||
const redirectURI = `${window.location.origin}/auth/oidc/callback`
|
||||
sessionStorage.setItem('fancydns.oidc.pending', JSON.stringify({ state, verifier, next }))
|
||||
const authorize = new URL(discovery.authorization_endpoint)
|
||||
authorize.search = new URLSearchParams({
|
||||
response_type: 'code', client_id: oidc.client_id, redirect_uri: redirectURI,
|
||||
scope: 'openid profile email', state, code_challenge: codeChallenge, code_challenge_method: 'S256',
|
||||
}).toString()
|
||||
window.location.assign(authorize.toString())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'could not start OIDC sign in')
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (pending) return
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
if (mode === 'oidc') {
|
||||
await startOIDC()
|
||||
return
|
||||
}
|
||||
if (wantsPassword(mode ?? 'apikey')) {
|
||||
await login({ username, password })
|
||||
} else {
|
||||
await login({ api_key: apiKey })
|
||||
}
|
||||
// A full navigation: the shell reads whoami and the token on boot.
|
||||
window.location.assign(next.startsWith('/') ? next : '/')
|
||||
window.location.assign(safeNext(next))
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
@@ -94,12 +184,12 @@ export default function Login() {
|
||||
? 'Checking the authentication mode…'
|
||||
: passwordMode
|
||||
? 'This node uses username and password.'
|
||||
: 'This node uses an API key.'
|
||||
: mode === 'oidc' ? 'Sign in with your identity provider.' : 'This node uses an API key.'
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
|
||||
{passwordMode ? (
|
||||
{mode === 'oidc' ? null : passwordMode ? (
|
||||
<>
|
||||
<Input
|
||||
label="Username"
|
||||
@@ -152,7 +242,7 @@ export default function Login() {
|
||||
loading={pending}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{pending ? 'Signing in…' : 'Sign in'}
|
||||
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue with OIDC' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
|
||||
Reference in new issue
Block a user