diff --git a/Cargo.lock b/Cargo.lock index 2c3aa0a..9823d5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,6 +267,7 @@ name = "auth_core" version = "0.1.0" dependencies = [ "async-trait", + "axum", "color-eyre", "serde", "thiserror 2.0.17", diff --git a/apps/frontend/app/(loginform)/login/page.tsx b/apps/frontend/app/(loginform)/login/page.tsx index 0dd7a05..668b1fb 100644 --- a/apps/frontend/app/(loginform)/login/page.tsx +++ b/apps/frontend/app/(loginform)/login/page.tsx @@ -15,12 +15,14 @@ import { AnimatePresence, motion } from 'motion/react'; import { RecoveryCode } from './recovery-code'; import { WebAuthn } from './webauthn'; import { WebAuthnPasswordless } from './webauthn-passwordless'; +import { Pgp } from './pgp'; export const formSchema = z.object({ username: z.string().min(1, 'Username is required'), password: z.string().optional(), totp: z.string().optional(), recovery_code: z.string().optional(), + pgp_signature: z.string().optional(), }); export type FormSchema = z.infer; @@ -48,6 +50,7 @@ export default function Page() { password: '', totp: '', recovery_code: '', + pgp_signature: '', }, }); @@ -69,6 +72,7 @@ export default function Page() { {screen === 'recoverycode' && } {screen === 'webauthn' && } {screen === 'webauthnpasswordless' && } + {screen === 'pgp' && }
diff --git a/apps/frontend/app/(loginform)/login/pgp.tsx b/apps/frontend/app/(loginform)/login/pgp.tsx new file mode 100644 index 0000000..cc0d097 --- /dev/null +++ b/apps/frontend/app/(loginform)/login/pgp.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { Alert, AlertDescription, AlertTitle } from '@components/ui/alert'; +import { Button } from '@components/ui/button'; +import { LinkComponent } from '@components/ui/link'; +import { LoginIcon } from '@components/ui/login-icon'; +import { $api } from '@lib/providers/api'; +import { IconAlertCircle, IconArrowRight, IconKey } from '@tabler/icons-react'; +import { useSetAtom } from 'jotai'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useLoginSuccess } from '@lib/hooks'; +import { FormSchema, screenAtom } from './page'; +import { ChallengeStep } from './pgp/challenge-step'; +import { QuickSignCommand } from './pgp/quick-sign-command'; +import { SignatureStep } from './pgp/signature-step'; + +export function Pgp() { + const setScreen = useSetAtom(screenAtom); + const form = useFormContext(); + const username = form.watch('username'); + const { onSuccess } = useLoginSuccess(); + + const [challenge, setChallenge] = useState(''); + const [challengeError, setChallengeError] = useState(''); + + const challengeRequest = $api.useMutation('get', '/api/login/pgp/challenge', { + onSuccess: ({ challenge }) => { + setChallenge(challenge); + setChallengeError(''); + }, + onError: (e) => { + if (!challenge) { + setChallengeError('Failed to generate challenge. Try again in a moment.'); + } + }, + }); + + const pgpLogin = $api.useMutation('post', '/api/login/pgp/challenge', { + onSuccess, + onError: (e) => { + form.setError('pgp_signature', { + message: e?.error || 'Login failed.', + }); + }, + }); + + const challengeRequestRef = useRef(challengeRequest); + challengeRequestRef.current = challengeRequest; + + const [refreshSpin, setRefreshSpin] = useState(0); + + const refreshChallenge = useCallback(() => { + form.clearErrors('pgp_signature'); + challengeRequestRef.current.mutate({}); + setRefreshSpin((s) => s + 1); + }, [form]); + + useEffect(() => { + refreshChallenge(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const gpgCommand = challenge ? `echo "${challenge}" | gpg --clearsign` : ''; + + return ( +
+ pgpLogin.mutate({ + body: { + username: data.username, + signature: data.pgp_signature ?? '', + }, + }) + )} + > + + + +
+

Sign in with a PGP key

+

+ Sign the server challenge for {username}{' '} + setScreen('welcome')}>Not you? +

+
+
+ {challengeError && ( + + + Challenge Unavailable + {challengeError} + + )} + + + + {challenge && } + + + + +
+ +
setScreen('login-options')}>More Options
+
+
+
+
+ ); +} diff --git a/apps/frontend/app/(loginform)/login/pgp/challenge-step.tsx b/apps/frontend/app/(loginform)/login/pgp/challenge-step.tsx new file mode 100644 index 0000000..8be98dc --- /dev/null +++ b/apps/frontend/app/(loginform)/login/pgp/challenge-step.tsx @@ -0,0 +1,44 @@ +import { IconRefresh } from '@tabler/icons-react'; +import { motion } from 'motion/react'; +import { CopyButton } from '@components/copy-button'; + +interface ChallengeStepProps { + challenge: string; + refreshChallenge: () => void; + isPending: boolean; + refreshSpin: number; +} + +export function ChallengeStep({ challenge, refreshChallenge, isPending, refreshSpin }: ChallengeStepProps) { + return ( +
+
+ +
+ + +
+
+
+ {challenge || Generating...} +
+
+ ); +} diff --git a/apps/frontend/app/(loginform)/login/pgp/quick-sign-command.tsx b/apps/frontend/app/(loginform)/login/pgp/quick-sign-command.tsx new file mode 100644 index 0000000..49c3fcb --- /dev/null +++ b/apps/frontend/app/(loginform)/login/pgp/quick-sign-command.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { IconTerminal2, IconChevronDown } from '@tabler/icons-react'; +import { AnimatePresence, motion } from 'motion/react'; +import { CopyButton } from '@components/copy-button'; + +interface QuickSignCommandProps { + gpgCommand: string; +} + +export function QuickSignCommand({ gpgCommand }: QuickSignCommandProps) { + const [showCommand, setShowCommand] = useState(false); + + return ( +
+ + + {showCommand && ( + +
+
+                                {gpgCommand}
+                            
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/app/(loginform)/login/pgp/signature-step.tsx b/apps/frontend/app/(loginform)/login/pgp/signature-step.tsx new file mode 100644 index 0000000..edae445 --- /dev/null +++ b/apps/frontend/app/(loginform)/login/pgp/signature-step.tsx @@ -0,0 +1,33 @@ +import { FormControl, FormField, FormItem, FormMessage } from '@components/ui/form'; +import { useFormContext } from 'react-hook-form'; +import { FormSchema } from '../page'; + +export function SignatureStep() { + const form = useFormContext(); + + return ( +
+ + ( + + +