Skip to content

Commit 62e7889

Browse files
committed
Implement passkey graphql API
1 parent 8253310 commit 62e7889

File tree

4 files changed

+242
-0
lines changed

4 files changed

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

graphql/schema.graphql

Lines changed: 32 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!): Boolean!
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!): JSON!
459484
}
460485

461486
interface Node {
@@ -527,6 +552,13 @@ 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+
530562
type Poll implements Node {
531563
ends: DateTime!
532564
id: ID!

0 commit comments

Comments
 (0)