1 parent
7fa48bcdf9
commit
a3d1614dca
15 files changed
+704
-123
No files matched your search
+36
-96
@@ -1,18 +1,11 @@
|
||||
/**
|
||||
* Login — the only page that renders outside AppShell.
|
||||
*
|
||||
* The daemon may be running with `auth.mode: none`, `apikey` or `basic`, so the
|
||||
* form is built from what `/api/whoami` reports rather than hard-coded. A 401
|
||||
* from that probe is expected (that is why the visitor is here) and is not
|
||||
* shown as an error.
|
||||
*/
|
||||
/** Sign-in uses the node's public auth settings. OIDC tokens stay on the server. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react'
|
||||
import { Globe, KeyRound, LogIn, ShieldCheck } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { ApiError, login, setToken } from '../lib/api'
|
||||
import { ApiError, login, clearToken } from '../lib/api'
|
||||
import { Button, Card, CardBody, Input } from '../components/ui'
|
||||
import { Spinner } from '../components/ui'
|
||||
|
||||
@@ -31,7 +24,6 @@ export default function Login() {
|
||||
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('')
|
||||
@@ -48,13 +40,13 @@ export default function Login() {
|
||||
.then((cfg) => {
|
||||
if (!live) return
|
||||
setMode(cfg.mode ?? 'apikey')
|
||||
if (cfg.issuer && cfg.client_id) setOIDC({ issuer: cfg.issuer, client_id: cfg.client_id })
|
||||
if (cfg.mode === 'none') window.location.replace(safeNext(next))
|
||||
})
|
||||
.catch(() => { if (live) setMode('apikey') })
|
||||
.catch(() => { if (live) setError('Could not load sign-in settings. Reload to try again.') })
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
}, [next])
|
||||
|
||||
useEffect(() => {
|
||||
const providerError = params.get('error')
|
||||
@@ -63,78 +55,37 @@ export default function Login() {
|
||||
return
|
||||
}
|
||||
const code = params.get('code')
|
||||
if (!code || !oidc || mode !== 'oidc') return
|
||||
if (callbackStarted.current) return
|
||||
if (!code || mode !== 'oidc' || 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')
|
||||
void fetch('/api/auth/oidc/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, state: params.get('state') }),
|
||||
}).then(async (res) => {
|
||||
const body = await res.json() as { next?: string; error?: string }
|
||||
if (!res.ok) throw new Error(body.error ?? 'Sign in failed')
|
||||
clearToken()
|
||||
window.location.replace(safeNext(body.next ?? '/'))
|
||||
}).catch((err: unknown) => {
|
||||
window.history.replaceState(null, '', '/auth/login')
|
||||
setError(err instanceof Error ? err.message : 'Sign in failed')
|
||||
setPending(false)
|
||||
}
|
||||
})
|
||||
}, [mode, params])
|
||||
|
||||
const startOIDC = (): void => {
|
||||
setPending(true)
|
||||
window.location.assign(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'oidc' && !params.has('code') && !params.has('error') && !params.has('signed_out') && !callbackStarted.current) {
|
||||
callbackStarted.current = true
|
||||
window.location.replace(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`)
|
||||
}
|
||||
}, [mode, next, params])
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (pending) return
|
||||
@@ -184,12 +135,12 @@ export default function Login() {
|
||||
? 'Checking the authentication mode…'
|
||||
: passwordMode
|
||||
? 'This node uses username and password.'
|
||||
: mode === 'oidc' ? 'Sign in with your identity provider.' : 'This node uses an API key.'
|
||||
: mode === 'oidc' ? 'Continue with your k6n account.' : 'This node uses an API key.'
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
|
||||
{mode === 'oidc' ? null : passwordMode ? (
|
||||
{mode === null || mode === 'oidc' ? null : passwordMode ? (
|
||||
<>
|
||||
<Input
|
||||
label="Username"
|
||||
@@ -236,30 +187,19 @@ export default function Login() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mode === null || pending}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={pending ? undefined : LogIn}
|
||||
loading={pending}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue with OIDC' : 'Sign in'}
|
||||
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue to sign in' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 text-xs text-fg-subtle">
|
||||
<Terminal size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
Scripts can skip this page: send the same credential as an{' '}
|
||||
<span className="font-mono text-fg-muted">X-API-Key</span> header, for example{' '}
|
||||
<span className="font-mono text-fg-muted">
|
||||
curl -H "X-API-Key: $KEY" http://node:8080/api/dashboard
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{pending && (
|
||||
<p className="mt-3 flex items-center justify-center gap-2 text-xs text-fg-muted">
|
||||
<Spinner size="sm" />
|
||||
|
||||
Reference in new issue
Block a user