Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions graphql/login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { negotiateLocale } from "@hackerspub/models/i18n";
import {
getAuthenticationOptions,
verifyAuthentication,
} from "@hackerspub/models/passkey";
import {
createSession,
deleteSession,
Expand All @@ -13,6 +17,7 @@ import {
} from "@hackerspub/models/signin";
import type { Uuid } from "@hackerspub/models/uuid";
import { getLogger } from "@logtape/logtape";
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
import { expandGlob } from "@std/fs";
import { join } from "@std/path";
import { createMessage, type Message } from "@upyo/core";
Expand Down Expand Up @@ -260,6 +265,63 @@ builder.mutationFields((t) => ({
return null;
},
}),

getPasskeyAuthenticationOptions: t.field({
type: "JSON",
args: {
sessionId: t.arg({
type: "UUID",
required: true,
description: "Temporary session ID for passkey authentication.",
}),
},
async resolve(_, args, ctx) {
const options = await getAuthenticationOptions(
ctx.kv,
ctx.fedCtx.canonicalOrigin,
args.sessionId as Uuid,
);
return options;
},
}),

loginByPasskey: t.field({
type: SessionRef,
nullable: true,
args: {
sessionId: t.arg({
type: "UUID",
required: true,
description: "Temporary session ID used for authentication options.",
}),
authenticationResponse: t.arg({
type: "JSON",
required: true,
description: "WebAuthn authentication response from the client.",
}),
},
async resolve(_, args, ctx) {
const result = await verifyAuthentication(
ctx.db,
ctx.kv,
ctx.fedCtx.canonicalOrigin,
args.sessionId as Uuid,
args.authenticationResponse as AuthenticationResponseJSON,
);
if (result == null) return null;
const { response, account } = result;
if (!response.verified) return null;

const remoteAddr = ctx.connectionInfo?.remoteAddr;
return await createSession(ctx.kv, {
accountId: account.id,
ipAddress: remoteAddr?.transport === "tcp"
? remoteAddr.hostname
: undefined,
userAgent: ctx.request.headers.get("User-Agent"),
});
},
}),
}));

const LOCALES_DIR = join(import.meta.dirname!, "locales");
Expand Down
1 change: 1 addition & 0 deletions graphql/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { builder } from "./builder.ts";
import "./doc.ts";
import "./login.ts";
import "./notification.ts";
import "./passkey.ts";
import "./poll.ts";
import "./post.ts";
import "./reactable.ts";
Expand Down
179 changes: 179 additions & 0 deletions graphql/passkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import {
getRegistrationOptions,
verifyRegistration,
} from "@hackerspub/models/passkey";
import { passkeyTable } from "@hackerspub/models/schema";
import {
encodeGlobalID,
resolveCursorConnection,
type ResolveCursorConnectionArgs,
} from "@pothos/plugin-relay";
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
import { and, desc, eq, gt, lt } from "drizzle-orm";
import { Account } from "./account.ts";
import { builder } from "./builder.ts";

export const Passkey = builder.drizzleNode("passkeyTable", {
name: "Passkey",
id: {
column: (passkey) => passkey.id,
},
fields: (t) => ({
name: t.exposeString("name"),
lastUsed: t.expose("lastUsed", { type: "DateTime", nullable: true }),
created: t.expose("created", { type: "DateTime" }),
}),
});

const PasskeyRegistrationResult = builder
.objectRef<{
verified: boolean;
passkey: typeof Passkey.$inferType | null;
}>("PasskeyRegistrationResult")
.implement({
fields: (t) => ({
verified: t.exposeBoolean("verified"),
passkey: t.field({
type: Passkey,
nullable: true,
resolve: (parent) => parent.passkey,
}),
}),
});

// Add passkeys connection to Account type
builder.objectField(Account, "passkeys", (t) =>
t.connection({
type: Passkey,
authScopes: (parent) => ({
selfAccount: parent.id,
}),
async resolve(account, args, ctx) {
return resolveCursorConnection(
{
args,
toCursor: (passkey) => passkey.created.valueOf().toString(),
},
async (
{ before, after, limit, inverted }: ResolveCursorConnectionArgs,
) => {
const beforeDate = before ? new Date(Number(before)) : undefined;
const afterDate = after ? new Date(Number(after)) : undefined;

return await ctx.db
.select()
.from(passkeyTable)
.where(
and(
eq(passkeyTable.accountId, account.id),
before
? inverted
? lt(passkeyTable.created, beforeDate!)
: gt(passkeyTable.created, beforeDate!)
: undefined,
after
? inverted
? gt(passkeyTable.created, afterDate!)
: lt(passkeyTable.created, afterDate!)
: undefined,
),
)
.orderBy(
inverted ? passkeyTable.created : desc(passkeyTable.created),
).limit(limit);
},
);
},
}));

builder.mutationFields((t) => ({
getPasskeyRegistrationOptions: t.field({
type: "JSON",
args: {
accountId: t.arg.globalID({ for: Account, required: true }),
},
async resolve(_, args, ctx) {
const session = await ctx.session;
if (session == null) throw new Error("Not authenticated.");
if (session.accountId !== args.accountId.id) {
throw new Error("Not authorized.");
}
const account = await ctx.db.query.accountTable.findFirst({
where: { id: args.accountId.id },
with: { passkeys: true },
});
if (account == null) throw new Error("Account not found.");
Comment on lines +96 to +105

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's significant code duplication for authentication, authorization, and account fetching logic across getPasskeyRegistrationOptions, verifyPasskeyRegistration, and revokePasskey mutations. This makes the code harder to maintain.

To improve this, you could extract the common logic into a helper function. This function would handle session checks and fetch the authorized account, which can then be used by all three mutations. This would centralize the logic, making it easier to update and test.

const options = await getRegistrationOptions(
ctx.kv,
ctx.fedCtx.canonicalOrigin,
account,
);
return options;
},
}),
verifyPasskeyRegistration: t.field({
type: PasskeyRegistrationResult,
args: {
accountId: t.arg.globalID({ for: Account, required: true }),
name: t.arg.string({ required: true }),
registrationResponse: t.arg({ type: "JSON", required: true }),
},
async resolve(_, args, ctx) {
const session = await ctx.session;
if (session == null) throw new Error("Not authenticated.");
if (session.accountId !== args.accountId.id) {
throw new Error("Not authorized.");
}
const account = await ctx.db.query.accountTable.findFirst({
where: { id: args.accountId.id },
with: { passkeys: true },
});
if (account == null) throw new Error("Account not found.");
const result = await verifyRegistration(
ctx.db,
ctx.kv,
ctx.fedCtx.canonicalOrigin,
account,
args.name,
args.registrationResponse as RegistrationResponseJSON,
);

Comment on lines +121 to +140
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enforce case-insensitive uniqueness of passkey names (server-side).

Currently neither server nor DB prevents duplicate names per account. UX-only checks can be bypassed. Add a DB unique index on (account_id, LOWER(name)) and optionally validate before insert to return a friendly error.

Proposed DB change (Drizzle + migration):

  • schema:
import { unique, sql } from "drizzle-orm/pg-core";
// …
unique()
  .on(passkeyTable.accountId, sql`LOWER(${passkeyTable.name})`)
  .name("passkey_account_id_name_lower_unique"),
  • SQL:
CREATE UNIQUE INDEX IF NOT EXISTS passkey_account_id_name_lower_unique
  ON passkey(account_id, LOWER(name));

Optional pre-check here to fail fast:

const exists = await ctx.db.query.passkeyTable.findFirst({
  where: and(
    eq(passkeyTable.accountId, account.id),
    sql`LOWER(${passkeyTable.name}) = LOWER(${args.name})`,
  ),
});
if (exists) throw new Error("Passkey name already exists.");

Would you like a follow-up patch/migration?

let passkey = null;
if (result.verified && result.registrationInfo != null) {
// Fetch the newly created passkey
passkey = await ctx.db.query.passkeyTable.findFirst({
where: {
id: result.registrationInfo.credential.id,
},
});
}

return {
verified: result.verified,
passkey: passkey || null,
};
},
}),
revokePasskey: t.field({
type: "ID",
nullable: true,
args: {
passkeyId: t.arg.globalID({ for: Passkey, required: true }),
},
async resolve(_, args, ctx) {
const session = await ctx.session;
if (session == null) throw new Error("Not authenticated.");
const passkey = await ctx.db.query.passkeyTable.findFirst({
where: { id: args.passkeyId.id },
});
if (passkey == null) return null;
if (passkey.accountId !== session.accountId) {
throw new Error("Not authorized.");
}
await ctx.db.delete(passkeyTable).where(
eq(passkeyTable.id, args.passkeyId.id),
);
return encodeGlobalID(Passkey.name, args.passkeyId.id);
},
}),
}));
37 changes: 37 additions & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Account implements Node {
moderator: Boolean!
name: String!
notifications(after: String, before: String, first: Int, last: Int): AccountNotificationsConnection!
passkeys(after: String, before: String, first: Int, last: Int): AccountPasskeysConnection!
preferAiSummary: Boolean!
updated: DateTime!
username: String!
Expand Down Expand Up @@ -95,6 +96,16 @@ type AccountNotificationsConnectionEdge {
node: Notification!
}

type AccountPasskeysConnection {
edges: [AccountPasskeysConnectionEdge!]!
pageInfo: PageInfo!
}

type AccountPasskeysConnectionEdge {
cursor: String!
node: Passkey!
}

type Actor implements Node {
account: Account
articles(after: String, before: String, first: Int, last: Int): ActorArticlesConnection!
Expand Down Expand Up @@ -425,6 +436,11 @@ type Mutation {
token: UUID!
): SignupResult!
createNote(input: CreateNoteInput!): CreateNoteResult!
getPasskeyAuthenticationOptions(
"""Temporary session ID for passkey authentication."""
sessionId: UUID!
): JSON!
getPasskeyRegistrationOptions(accountId: ID!): JSON!
loginByEmail(
"""The email of the account to sign in."""
email: String!
Expand All @@ -437,6 +453,13 @@ type Mutation {
"""
verifyUrl: URITemplate!
): LoginResult!
loginByPasskey(
"""WebAuthn authentication response from the client."""
authenticationResponse: JSON!

"""Temporary session ID used for authentication options."""
sessionId: UUID!
): Session
loginByUsername(
"""The locale for the sign-in email."""
locale: Locale!
Expand All @@ -449,13 +472,15 @@ type Mutation {
"""
verifyUrl: URITemplate!
): LoginResult!
revokePasskey(passkeyId: ID!): ID

"""Revoke a session by its ID."""
revokeSession(
"""The ID of the session to log out."""
sessionId: UUID!
): Session
updateAccount(input: UpdateAccountInput!): UpdateAccountPayload!
verifyPasskeyRegistration(accountId: ID!, name: String!, registrationResponse: JSON!): PasskeyRegistrationResult!
}

interface Node {
Expand Down Expand Up @@ -527,6 +552,18 @@ type PageInfo {
startCursor: String
}

type Passkey implements Node {
created: DateTime!
id: ID!
lastUsed: DateTime
name: String!
}

type PasskeyRegistrationResult {
passkey: Passkey
verified: Boolean!
}

type Poll implements Node {
ends: DateTime!
id: ID!
Expand Down
11 changes: 9 additions & 2 deletions web-next/src/components/SettingsTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { useLingui } from "~/lib/i18n/macro.d.ts";
import type { SettingsTabs_account$key } from "./__generated__/SettingsTabs_account.graphql.ts";

export type SettingsTab = "profile" | "preferences";
export type SettingsTab = "profile" | "preferences" | "passkeys";

export interface SettingsTabsProps {
selected: SettingsTab;
Expand All @@ -28,7 +28,7 @@ export function SettingsTabs(props: SettingsTabsProps) {
<Show when={account()}>
{(account) => (
<Tabs value={props.selected}>
<TabsList class="grid max-w-prose mx-auto grid-cols-4">
<TabsList class="grid max-w-prose mx-auto grid-cols-3">
<TabsTrigger
as={A}
value="profile"
Expand All @@ -43,6 +43,13 @@ export function SettingsTabs(props: SettingsTabsProps) {
>
{t`Preferences`}
</TabsTrigger>
<TabsTrigger
as={A}
value="passkeys"
href={`/@${account().username}/settings/passkeys`}
>
{t`Passkeys`}
</TabsTrigger>
</TabsList>
</Tabs>
)}
Expand Down
Loading
Loading