-
Notifications
You must be signed in to change notification settings - Fork 30
web-next: migrate passkey feature #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eada2ca
2c56a06
1e2e9be
2966f7b
19866a6
5712c82
4efd64e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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."); | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Proposed DB change (Drizzle + migration):
import { unique, sql } from "drizzle-orm/pg-core";
// …
unique()
.on(passkeyTable.accountId, sql`LOWER(${passkeyTable.name})`)
.name("passkey_account_id_name_lower_unique"),
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); | ||
}, | ||
}), | ||
})); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's significant code duplication for authentication, authorization, and account fetching logic across
getPasskeyRegistrationOptions
,verifyPasskeyRegistration
, andrevokePasskey
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.