Skip to content

Commit d6f7351

Browse files
committed
Implement login by passkey
1 parent fd8e805 commit d6f7351

File tree

1 file changed

+138
-1
lines changed

1 file changed

+138
-1
lines changed

web-next/src/routes/(root)/sign/index.tsx

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { Uuid } from "@hackerspub/models/uuid";
2+
import {
3+
type AuthenticationResponseJSON,
4+
type PublicKeyCredentialRequestOptionsJSON,
5+
startAuthentication,
6+
} from "@simplewebauthn/browser";
27
import { graphql } from "relay-runtime";
3-
import { createSignal, Show } from "solid-js";
8+
import { createSignal, onMount, Show } from "solid-js";
49
import { getRequestEvent } from "solid-js/web";
510
import { createMutation } from "solid-relay";
611
import { getRequestProtocol, setCookie } from "vinxi/http";
@@ -19,15 +24,18 @@ import {
1924
TextFieldInput,
2025
TextFieldLabel,
2126
} from "~/components/ui/text-field.tsx";
27+
import { showToast } from "~/components/ui/toast.tsx";
2228
import { useLingui } from "~/lib/i18n/macro.d.ts";
2329
import type {
2430
signByEmailMutation,
2531
} from "./__generated__/signByEmailMutation.graphql.ts";
32+
import type { signByPasskeyMutation } from "./__generated__/signByPasskeyMutation.graphql.ts";
2633
import type {
2734
signByUsernameMutation,
2835
signByUsernameMutation$data,
2936
} from "./__generated__/signByUsernameMutation.graphql.ts";
3037
import type { signCompleteMutation } from "./__generated__/signCompleteMutation.graphql.ts";
38+
import type { signGetPasskeyAuthenticationOptionsMutation } from "./__generated__/signGetPasskeyAuthenticationOptionsMutation.graphql.ts";
3139

3240
const signByEmailMutation = graphql`
3341
mutation signByEmailMutation($locale: Locale!, $email: String!, $verifyUrl: URITemplate!) {
@@ -75,6 +83,20 @@ const signCompleteMutation = graphql`
7583
}
7684
`;
7785

86+
const signGetPasskeyAuthenticationOptionsMutation = graphql`
87+
mutation signGetPasskeyAuthenticationOptionsMutation($sessionId: UUID!) {
88+
getPasskeyAuthenticationOptions(sessionId: $sessionId)
89+
}
90+
`;
91+
92+
const signByPasskeyMutation = graphql`
93+
mutation signByPasskeyMutation($sessionId: UUID!, $authenticationResponse: JSON!) {
94+
loginByPasskey(sessionId: $sessionId, authenticationResponse: $authenticationResponse) {
95+
id
96+
}
97+
}
98+
`;
99+
78100
const setSessionCookie = async (sessionId: Uuid) => {
79101
"use server";
80102
const event = getRequestEvent();
@@ -115,7 +137,25 @@ export default function SignPage() {
115137
const [complete] = createMutation<signCompleteMutation>(
116138
signCompleteMutation,
117139
);
140+
const [getPasskeyOptions] = createMutation<
141+
signGetPasskeyAuthenticationOptionsMutation
142+
>(
143+
signGetPasskeyAuthenticationOptionsMutation,
144+
);
145+
const [loginByPasskey] = createMutation<signByPasskeyMutation>(
146+
signByPasskeyMutation,
147+
);
118148
const [completing, setCompleting] = createSignal(false);
149+
const [passkeyAuthenticating, setPasskeyAuthenticating] = createSignal(false);
150+
const [autoPasskeyAttempted, setAutoPasskeyAttempted] = createSignal(false);
151+
152+
onMount(() => {
153+
// Automatically attempt passkey authentication when page loads
154+
if (!autoPasskeyAttempted()) {
155+
setAutoPasskeyAttempted(true);
156+
onPasskeyLogin(false);
157+
}
158+
});
119159

120160
function onInput() {
121161
if (emailInput == null) return;
@@ -238,6 +278,80 @@ export default function SignPage() {
238278
}
239279
}
240280

281+
async function onPasskeyLogin(showError: boolean) {
282+
setPasskeyAuthenticating(true);
283+
284+
try {
285+
// Generate a temporary session ID for this authentication attempt
286+
const tempSessionId = crypto.randomUUID();
287+
288+
// Get authentication options
289+
const optionsResponse = await new Promise<
290+
signGetPasskeyAuthenticationOptionsMutation["response"]
291+
>((resolve, reject) => {
292+
getPasskeyOptions({
293+
variables: { sessionId: tempSessionId },
294+
onCompleted: resolve,
295+
onError: reject,
296+
});
297+
});
298+
299+
const options = optionsResponse.getPasskeyAuthenticationOptions;
300+
if (!options || typeof options !== "object") {
301+
throw new Error("Invalid authentication options");
302+
}
303+
304+
// Start WebAuthn authentication
305+
let authenticationResponse: AuthenticationResponseJSON;
306+
try {
307+
authenticationResponse = await startAuthentication({
308+
optionsJSON: options as PublicKeyCredentialRequestOptionsJSON,
309+
});
310+
} catch (error) {
311+
throw new Error(
312+
error instanceof Error ? error.message : "Authentication failed",
313+
);
314+
}
315+
316+
// Verify authentication and get session
317+
const loginResponse = await new Promise<
318+
signByPasskeyMutation["response"]
319+
>((resolve, reject) => {
320+
loginByPasskey({
321+
variables: {
322+
sessionId: tempSessionId,
323+
authenticationResponse,
324+
},
325+
onCompleted: resolve,
326+
onError: reject,
327+
});
328+
});
329+
330+
if (loginResponse.loginByPasskey?.id) {
331+
const success = await setSessionCookie(loginResponse.loginByPasskey.id);
332+
if (success) {
333+
const searchParams = location == null
334+
? new URLSearchParams()
335+
: new URL(location.href).searchParams;
336+
window.location.href = searchParams.get("next") ?? "/";
337+
} else {
338+
throw new Error("Failed to set session cookie");
339+
}
340+
} else {
341+
throw new Error("Authentication verification failed");
342+
}
343+
} catch (_) {
344+
if (showError) {
345+
showToast({
346+
title: t`Passkey authentication failed`,
347+
variant: "destructive",
348+
});
349+
}
350+
} finally {
351+
setPasskeyAuthenticating(false);
352+
}
353+
}
354+
241355
return (
242356
<div
243357
lang={i18n.locale}
@@ -283,6 +397,29 @@ export default function SignPage() {
283397
</Button>
284398
</Grid>
285399
</form>
400+
<div class="relative my-6">
401+
<div class="absolute inset-0 flex items-center">
402+
<span class="w-full border-t" />
403+
</div>
404+
<div class="relative flex justify-center text-xs uppercase">
405+
<span class="bg-background px-2 text-muted-foreground">
406+
{t`Or`}
407+
</span>
408+
</div>
409+
</div>
410+
<div class="mb-6">
411+
<Button
412+
type="button"
413+
variant="outline"
414+
disabled={passkeyAuthenticating()}
415+
onClick={() => onPasskeyLogin(true)}
416+
class="w-full cursor-pointer"
417+
>
418+
{passkeyAuthenticating()
419+
? t`Authenticating...`
420+
: t`Sign in with passkey`}
421+
</Button>
422+
</div>
286423
<div class="text-center">
287424
<p class="text-sm text-muted-foreground">
288425
{t`Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you.`}

0 commit comments

Comments
 (0)