Skip to content

Commit e1f3044

Browse files
authored
Merge pull request #141 from Perlmint/feature/passkeys
2 parents 01497e1 + 4efd64e commit e1f3044

File tree

13 files changed

+1858
-53
lines changed

13 files changed

+1858
-53
lines changed

graphql/login.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { negotiateLocale } from "@hackerspub/models/i18n";
2+
import {
3+
getAuthenticationOptions,
4+
verifyAuthentication,
5+
} from "@hackerspub/models/passkey";
26
import {
37
createSession,
48
deleteSession,
@@ -13,6 +17,7 @@ import {
1317
} from "@hackerspub/models/signin";
1418
import type { Uuid } from "@hackerspub/models/uuid";
1519
import { getLogger } from "@logtape/logtape";
20+
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
1621
import { expandGlob } from "@std/fs";
1722
import { join } from "@std/path";
1823
import { createMessage, type Message } from "@upyo/core";
@@ -260,6 +265,63 @@ builder.mutationFields((t) => ({
260265
return null;
261266
},
262267
}),
268+
269+
getPasskeyAuthenticationOptions: t.field({
270+
type: "JSON",
271+
args: {
272+
sessionId: t.arg({
273+
type: "UUID",
274+
required: true,
275+
description: "Temporary session ID for passkey authentication.",
276+
}),
277+
},
278+
async resolve(_, args, ctx) {
279+
const options = await getAuthenticationOptions(
280+
ctx.kv,
281+
ctx.fedCtx.canonicalOrigin,
282+
args.sessionId as Uuid,
283+
);
284+
return options;
285+
},
286+
}),
287+
288+
loginByPasskey: t.field({
289+
type: SessionRef,
290+
nullable: true,
291+
args: {
292+
sessionId: t.arg({
293+
type: "UUID",
294+
required: true,
295+
description: "Temporary session ID used for authentication options.",
296+
}),
297+
authenticationResponse: t.arg({
298+
type: "JSON",
299+
required: true,
300+
description: "WebAuthn authentication response from the client.",
301+
}),
302+
},
303+
async resolve(_, args, ctx) {
304+
const result = await verifyAuthentication(
305+
ctx.db,
306+
ctx.kv,
307+
ctx.fedCtx.canonicalOrigin,
308+
args.sessionId as Uuid,
309+
args.authenticationResponse as AuthenticationResponseJSON,
310+
);
311+
if (result == null) return null;
312+
const { response, account } = result;
313+
if (!response.verified) return null;
314+
315+
const remoteAddr = ctx.connectionInfo?.remoteAddr;
316+
return await createSession(ctx.kv, {
317+
accountId: account.id,
318+
ipAddress: remoteAddr?.transport === "tcp"
319+
? remoteAddr.hostname
320+
: undefined,
321+
userAgent: ctx.request.headers.get("User-Agent"),
322+
});
323+
},
324+
}),
263325
}));
264326

265327
const LOCALES_DIR = join(import.meta.dirname!, "locales");

graphql/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { builder } from "./builder.ts";
66
import "./doc.ts";
77
import "./login.ts";
88
import "./notification.ts";
9+
import "./passkey.ts";
910
import "./poll.ts";
1011
import "./post.ts";
1112
import "./reactable.ts";

graphql/passkey.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {
2+
getRegistrationOptions,
3+
verifyRegistration,
4+
} from "@hackerspub/models/passkey";
5+
import { passkeyTable } from "@hackerspub/models/schema";
6+
import {
7+
encodeGlobalID,
8+
resolveCursorConnection,
9+
type ResolveCursorConnectionArgs,
10+
} from "@pothos/plugin-relay";
11+
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
12+
import { and, desc, eq, gt, lt } from "drizzle-orm";
13+
import { Account } from "./account.ts";
14+
import { builder } from "./builder.ts";
15+
16+
export const Passkey = builder.drizzleNode("passkeyTable", {
17+
name: "Passkey",
18+
id: {
19+
column: (passkey) => passkey.id,
20+
},
21+
fields: (t) => ({
22+
name: t.exposeString("name"),
23+
lastUsed: t.expose("lastUsed", { type: "DateTime", nullable: true }),
24+
created: t.expose("created", { type: "DateTime" }),
25+
}),
26+
});
27+
28+
const PasskeyRegistrationResult = builder
29+
.objectRef<{
30+
verified: boolean;
31+
passkey: typeof Passkey.$inferType | null;
32+
}>("PasskeyRegistrationResult")
33+
.implement({
34+
fields: (t) => ({
35+
verified: t.exposeBoolean("verified"),
36+
passkey: t.field({
37+
type: Passkey,
38+
nullable: true,
39+
resolve: (parent) => parent.passkey,
40+
}),
41+
}),
42+
});
43+
44+
// Add passkeys connection to Account type
45+
builder.objectField(Account, "passkeys", (t) =>
46+
t.connection({
47+
type: Passkey,
48+
authScopes: (parent) => ({
49+
selfAccount: parent.id,
50+
}),
51+
async resolve(account, args, ctx) {
52+
return resolveCursorConnection(
53+
{
54+
args,
55+
toCursor: (passkey) => passkey.created.valueOf().toString(),
56+
},
57+
async (
58+
{ before, after, limit, inverted }: ResolveCursorConnectionArgs,
59+
) => {
60+
const beforeDate = before ? new Date(Number(before)) : undefined;
61+
const afterDate = after ? new Date(Number(after)) : undefined;
62+
63+
return await ctx.db
64+
.select()
65+
.from(passkeyTable)
66+
.where(
67+
and(
68+
eq(passkeyTable.accountId, account.id),
69+
before
70+
? inverted
71+
? lt(passkeyTable.created, beforeDate!)
72+
: gt(passkeyTable.created, beforeDate!)
73+
: undefined,
74+
after
75+
? inverted
76+
? gt(passkeyTable.created, afterDate!)
77+
: lt(passkeyTable.created, afterDate!)
78+
: undefined,
79+
),
80+
)
81+
.orderBy(
82+
inverted ? passkeyTable.created : desc(passkeyTable.created),
83+
).limit(limit);
84+
},
85+
);
86+
},
87+
}));
88+
89+
builder.mutationFields((t) => ({
90+
getPasskeyRegistrationOptions: t.field({
91+
type: "JSON",
92+
args: {
93+
accountId: t.arg.globalID({ for: Account, required: true }),
94+
},
95+
async resolve(_, args, ctx) {
96+
const session = await ctx.session;
97+
if (session == null) throw new Error("Not authenticated.");
98+
if (session.accountId !== args.accountId.id) {
99+
throw new Error("Not authorized.");
100+
}
101+
const account = await ctx.db.query.accountTable.findFirst({
102+
where: { id: args.accountId.id },
103+
with: { passkeys: true },
104+
});
105+
if (account == null) throw new Error("Account not found.");
106+
const options = await getRegistrationOptions(
107+
ctx.kv,
108+
ctx.fedCtx.canonicalOrigin,
109+
account,
110+
);
111+
return options;
112+
},
113+
}),
114+
verifyPasskeyRegistration: t.field({
115+
type: PasskeyRegistrationResult,
116+
args: {
117+
accountId: t.arg.globalID({ for: Account, required: true }),
118+
name: t.arg.string({ required: true }),
119+
registrationResponse: t.arg({ type: "JSON", required: true }),
120+
},
121+
async resolve(_, args, ctx) {
122+
const session = await ctx.session;
123+
if (session == null) throw new Error("Not authenticated.");
124+
if (session.accountId !== args.accountId.id) {
125+
throw new Error("Not authorized.");
126+
}
127+
const account = await ctx.db.query.accountTable.findFirst({
128+
where: { id: args.accountId.id },
129+
with: { passkeys: true },
130+
});
131+
if (account == null) throw new Error("Account not found.");
132+
const result = await verifyRegistration(
133+
ctx.db,
134+
ctx.kv,
135+
ctx.fedCtx.canonicalOrigin,
136+
account,
137+
args.name,
138+
args.registrationResponse as RegistrationResponseJSON,
139+
);
140+
141+
let passkey = null;
142+
if (result.verified && result.registrationInfo != null) {
143+
// Fetch the newly created passkey
144+
passkey = await ctx.db.query.passkeyTable.findFirst({
145+
where: {
146+
id: result.registrationInfo.credential.id,
147+
},
148+
});
149+
}
150+
151+
return {
152+
verified: result.verified,
153+
passkey: passkey || null,
154+
};
155+
},
156+
}),
157+
revokePasskey: t.field({
158+
type: "ID",
159+
nullable: true,
160+
args: {
161+
passkeyId: t.arg.globalID({ for: Passkey, required: true }),
162+
},
163+
async resolve(_, args, ctx) {
164+
const session = await ctx.session;
165+
if (session == null) throw new Error("Not authenticated.");
166+
const passkey = await ctx.db.query.passkeyTable.findFirst({
167+
where: { id: args.passkeyId.id },
168+
});
169+
if (passkey == null) return null;
170+
if (passkey.accountId !== session.accountId) {
171+
throw new Error("Not authorized.");
172+
}
173+
await ctx.db.delete(passkeyTable).where(
174+
eq(passkeyTable.id, args.passkeyId.id),
175+
);
176+
return encodeGlobalID(Passkey.name, args.passkeyId.id);
177+
},
178+
}),
179+
}));

graphql/schema.graphql

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type Account implements Node {
1515
moderator: Boolean!
1616
name: String!
1717
notifications(after: String, before: String, first: Int, last: Int): AccountNotificationsConnection!
18+
passkeys(after: String, before: String, first: Int, last: Int): AccountPasskeysConnection!
1819
preferAiSummary: Boolean!
1920
updated: DateTime!
2021
username: String!
@@ -95,6 +96,16 @@ type AccountNotificationsConnectionEdge {
9596
node: Notification!
9697
}
9798

99+
type AccountPasskeysConnection {
100+
edges: [AccountPasskeysConnectionEdge!]!
101+
pageInfo: PageInfo!
102+
}
103+
104+
type AccountPasskeysConnectionEdge {
105+
cursor: String!
106+
node: Passkey!
107+
}
108+
98109
type Actor implements Node {
99110
account: Account
100111
articles(after: String, before: String, first: Int, last: Int): ActorArticlesConnection!
@@ -425,6 +436,11 @@ type Mutation {
425436
token: UUID!
426437
): SignupResult!
427438
createNote(input: CreateNoteInput!): CreateNoteResult!
439+
getPasskeyAuthenticationOptions(
440+
"""Temporary session ID for passkey authentication."""
441+
sessionId: UUID!
442+
): JSON!
443+
getPasskeyRegistrationOptions(accountId: ID!): JSON!
428444
loginByEmail(
429445
"""The email of the account to sign in."""
430446
email: String!
@@ -437,6 +453,13 @@ type Mutation {
437453
"""
438454
verifyUrl: URITemplate!
439455
): LoginResult!
456+
loginByPasskey(
457+
"""WebAuthn authentication response from the client."""
458+
authenticationResponse: JSON!
459+
460+
"""Temporary session ID used for authentication options."""
461+
sessionId: UUID!
462+
): Session
440463
loginByUsername(
441464
"""The locale for the sign-in email."""
442465
locale: Locale!
@@ -449,13 +472,15 @@ type Mutation {
449472
"""
450473
verifyUrl: URITemplate!
451474
): LoginResult!
475+
revokePasskey(passkeyId: ID!): ID
452476

453477
"""Revoke a session by its ID."""
454478
revokeSession(
455479
"""The ID of the session to log out."""
456480
sessionId: UUID!
457481
): Session
458482
updateAccount(input: UpdateAccountInput!): UpdateAccountPayload!
483+
verifyPasskeyRegistration(accountId: ID!, name: String!, registrationResponse: JSON!): PasskeyRegistrationResult!
459484
}
460485

461486
interface Node {
@@ -527,6 +552,18 @@ type PageInfo {
527552
startCursor: String
528553
}
529554

555+
type Passkey implements Node {
556+
created: DateTime!
557+
id: ID!
558+
lastUsed: DateTime
559+
name: String!
560+
}
561+
562+
type PasskeyRegistrationResult {
563+
passkey: Passkey
564+
verified: Boolean!
565+
}
566+
530567
type Poll implements Node {
531568
ends: DateTime!
532569
id: ID!

web-next/src/components/SettingsTabs.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
66
import { useLingui } from "~/lib/i18n/macro.d.ts";
77
import type { SettingsTabs_account$key } from "./__generated__/SettingsTabs_account.graphql.ts";
88

9-
export type SettingsTab = "profile" | "preferences";
9+
export type SettingsTab = "profile" | "preferences" | "passkeys";
1010

1111
export interface SettingsTabsProps {
1212
selected: SettingsTab;
@@ -28,7 +28,7 @@ export function SettingsTabs(props: SettingsTabsProps) {
2828
<Show when={account()}>
2929
{(account) => (
3030
<Tabs value={props.selected}>
31-
<TabsList class="grid max-w-prose mx-auto grid-cols-4">
31+
<TabsList class="grid max-w-prose mx-auto grid-cols-3">
3232
<TabsTrigger
3333
as={A}
3434
value="profile"
@@ -43,6 +43,13 @@ export function SettingsTabs(props: SettingsTabsProps) {
4343
>
4444
{t`Preferences`}
4545
</TabsTrigger>
46+
<TabsTrigger
47+
as={A}
48+
value="passkeys"
49+
href={`/@${account().username}/settings/passkeys`}
50+
>
51+
{t`Passkeys`}
52+
</TabsTrigger>
4653
</TabsList>
4754
</Tabs>
4855
)}

0 commit comments

Comments
 (0)