From cf1e21409e7298fdc6f04dabc3f9ec1433d52bcc Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 17:58:36 +0800 Subject: [PATCH] feat: add passkey registration endpoint --- .../passkey/authenticate/options/route.ts | 84 +++++++ .../passkey/authenticate/verify/route.test.ts | 236 ++++++++++++++++++ .../passkey/authenticate/verify/route.ts | 137 ++++++++++ .../oauth/passkey/register/options/route.ts | 99 ++++++++ .../passkey/register/verify/route.test.ts | 224 +++++++++++++++++ .../oauth/passkey/register/verify/route.ts | 177 +++++++++++++ app/api/oauth/signup/route.ts | 65 ++--- app/api/oauth/token/password-grant.test.ts | 4 + app/api/oauth/token/route.ts | 54 +--- lib/oauth/issue-tokens.ts | 93 +++++++ lib/oauth/native-client.ts | 74 ++++++ lib/redis/index.ts | 11 +- lib/validations/oauth.test.ts | 131 ++++++++++ lib/validations/oauth.ts | 79 ++++++ 14 files changed, 1368 insertions(+), 100 deletions(-) create mode 100644 app/api/oauth/passkey/authenticate/options/route.ts create mode 100644 app/api/oauth/passkey/authenticate/verify/route.test.ts create mode 100644 app/api/oauth/passkey/authenticate/verify/route.ts create mode 100644 app/api/oauth/passkey/register/options/route.ts create mode 100644 app/api/oauth/passkey/register/verify/route.test.ts create mode 100644 app/api/oauth/passkey/register/verify/route.ts create mode 100644 lib/oauth/issue-tokens.ts create mode 100644 lib/oauth/native-client.ts diff --git a/app/api/oauth/passkey/authenticate/options/route.ts b/app/api/oauth/passkey/authenticate/options/route.ts new file mode 100644 index 0000000..54651fb --- /dev/null +++ b/app/api/oauth/passkey/authenticate/options/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { users, passkeys } from "@/lib/db/schema"; +import { + storeWebAuthnChallenge, + type WebAuthnChallengeData, +} from "@/lib/redis"; +import { + getAuthenticationOptions, + parseTransports, +} from "@/lib/webauthn/config"; +import { passkeyAuthOptionsRequestSchema } from "@/lib/validations/oauth"; +import { validateNativeClient } from "@/lib/oauth/native-client"; + +// POST /api/oauth/passkey/authenticate/options +// +// Native passkey sign-in: returns a WebAuthn assertion options blob + a +// session id that the macOS client passes back to /verify. No cookie or +// pre-existing session is required. +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "invalid_request", error_description: "Body must be JSON" }, + { status: 400 }, + ); + } + + const parsed = passkeyAuthOptionsRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "invalid_request", + error_description: parsed.error.issues[0]?.message, + }, + { status: 400 }, + ); + } + + const check = await validateNativeClient(parsed.data.client_id); + if (!check.ok) return check.response; + + const sessionId = crypto.randomUUID(); + + let allowCredentials: { + id: string; + transports?: AuthenticatorTransport[]; + }[] = []; + + if (parsed.data.username) { + const user = await db.query.users.findFirst({ + where: eq(users.email, parsed.data.username.toLowerCase()), + }); + if (user) { + const userPasskeys = await db.query.passkeys.findMany({ + where: eq(passkeys.userId, user.id), + }); + allowCredentials = userPasskeys.map((pk) => ({ + id: pk.id, + transports: parseTransports(pk.transports), + })); + } + } + + const options = getAuthenticationOptions(allowCredentials); + const authenticationOptions = await generateAuthenticationOptions(options); + + const challengeData: WebAuthnChallengeData = { + challenge: authenticationOptions.challenge, + type: "native-authentication", + clientId: parsed.data.client_id, + createdAt: Date.now(), + }; + await storeWebAuthnChallenge(sessionId, challengeData); + + return NextResponse.json({ + ...authenticationOptions, + sessionId, + }); +} diff --git a/app/api/oauth/passkey/authenticate/verify/route.test.ts b/app/api/oauth/passkey/authenticate/verify/route.test.ts new file mode 100644 index 0000000..8f12245 --- /dev/null +++ b/app/api/oauth/passkey/authenticate/verify/route.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; + +const VALID_CLIENT = { + id: "macos-test-app", + clientType: "public" as const, + secret: null as string | null, + name: "macOS Test App", + description: null, + iconUrl: null, + redirectUris: JSON.stringify(["rxauthswift://callback"]), + allowedScopes: JSON.stringify(["openid", "read:profile", "read:email"]), + isFirstParty: true, + signInPermission: "all" as const, + permissions: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const VALID_USER = { + id: "user-1", + email: "user@example.com", + emailVerified: true, + passwordHash: null, + username: null, + displayName: "Test User", + avatarSeed: null, + avatarUrl: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const VALID_PASSKEY = { + id: "cred-id", + userId: "user-1", + name: "MacBook", + publicKey: "base64url-pubkey", + counter: 0, + deviceType: "platform" as const, + backedUp: true, + transports: null, + createdAt: new Date(), + lastUsedAt: null, +}; + +const VALID_CHALLENGE = { + challenge: "challenge-stub", + type: "native-authentication" as const, + clientId: VALID_CLIENT.id, + createdAt: Date.now(), +}; + +const findClient = mock(); +const findUser = mock(); +const findPasskey = mock(); +const getChallengeMock = mock(); +const deleteChallengeMock = mock(); +const verifyAuthenticationResponseMock = mock(); +const signAccessTokenMock = mock(); +const signIdTokenMock = mock(); +const generateRefreshTokenMock = mock(); +const insertValues = mock(); +const updateSet = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + oauthClients: { findFirst: findClient }, + users: { findFirst: findUser }, + passkeys: { findFirst: findPasskey }, + }, + insert: () => ({ values: insertValues }), + update: () => ({ set: updateSet }), + }, +})); + +mock.module("@/lib/redis", () => ({ + getWebAuthnChallenge: getChallengeMock, + deleteWebAuthnChallenge: deleteChallengeMock, + storeWebAuthnChallenge: mock(), + getOAuthCode: mock(), + deleteOAuthCode: mock(), + storeOAuthCode: mock(), +})); + +mock.module("@simplewebauthn/server", () => ({ + verifyAuthenticationResponse: verifyAuthenticationResponseMock, + generateAuthenticationOptions: mock(), + generateRegistrationOptions: mock(), + verifyRegistrationResponse: mock(), +})); + +mock.module("@/lib/oauth/jwt", () => ({ + signAccessToken: signAccessTokenMock, + signIdToken: signIdTokenMock, + generateRefreshToken: generateRefreshTokenMock, +})); + +const { POST } = await import("./route"); + +function makeRequest(body: Record): Request { + return new Request( + "https://auth.rxlab.app/api/oauth/passkey/authenticate/verify", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +const VALID_BODY = { + client_id: VALID_CLIENT.id, + session_id: "session-id", + credential: { + id: "cred-id", + rawId: "cred-id", + type: "public-key", + response: { + clientDataJSON: "abc", + authenticatorData: "def", + signature: "ghi", + userHandle: "user-handle", + }, + }, + scope: "openid read:email", +}; + +describe("POST /api/oauth/passkey/authenticate/verify", () => { + beforeEach(() => { + findClient.mockReset(); + findUser.mockReset(); + findPasskey.mockReset(); + getChallengeMock.mockReset(); + deleteChallengeMock.mockReset(); + verifyAuthenticationResponseMock.mockReset(); + signAccessTokenMock.mockReset(); + signIdTokenMock.mockReset(); + generateRefreshTokenMock.mockReset(); + insertValues.mockReset(); + updateSet.mockReset(); + + findClient.mockResolvedValue(VALID_CLIENT); + findUser.mockResolvedValue(VALID_USER); + findPasskey.mockResolvedValue(VALID_PASSKEY); + getChallengeMock.mockResolvedValue(VALID_CHALLENGE); + deleteChallengeMock.mockResolvedValue(undefined); + verifyAuthenticationResponseMock.mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 1 }, + }); + signAccessTokenMock.mockResolvedValue("access-token-stub"); + signIdTokenMock.mockResolvedValue("id-token-stub"); + generateRefreshTokenMock.mockReturnValue("refresh-token-stub"); + insertValues.mockResolvedValue(undefined); + updateSet.mockReturnValue({ where: () => Promise.resolve() }); + }); + + test("happy path: issues tokens matching /api/oauth/token shape", async () => { + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.access_token).toBe("access-token-stub"); + expect(body.refresh_token).toBe("refresh-token-stub"); + expect(body.id_token).toBe("id-token-stub"); + expect(body.token_type).toBe("Bearer"); + expect(body.expires_in).toBe(3600); + expect(body.scope).toBe("openid read:email"); + expect(deleteChallengeMock).toHaveBeenCalledWith("session-id"); + }); + + test("invalid_client: rejects unknown client_id", async () => { + findClient.mockResolvedValue(undefined); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_client"); + }); + + test("unauthorized_client: rejects client with signInPermission != all", async () => { + findClient.mockResolvedValue({ + ...VALID_CLIENT, + signInPermission: "whitelist", + }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("unauthorized_client"); + }); + + test("invalid_grant: no challenge stored for session_id", async () => { + getChallengeMock.mockResolvedValue(null); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_grant: challenge belongs to different client", async () => { + getChallengeMock.mockResolvedValue({ + ...VALID_CHALLENGE, + clientId: "different-client", + }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_grant: challenge has wrong type (e.g. browser registration)", async () => { + getChallengeMock.mockResolvedValue({ + ...VALID_CHALLENGE, + type: "registration", + }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_grant: unknown credential id", async () => { + findPasskey.mockResolvedValue(undefined); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_grant: WebAuthn verification fails", async () => { + verifyAuthenticationResponseMock.mockResolvedValue({ verified: false }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_scope: scope outside client's allowed_scopes", async () => { + const res = await POST( + makeRequest({ ...VALID_BODY, scope: "openid admin:all" }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_scope"); + }); +}); diff --git a/app/api/oauth/passkey/authenticate/verify/route.ts b/app/api/oauth/passkey/authenticate/verify/route.ts new file mode 100644 index 0000000..b7c3a7a --- /dev/null +++ b/app/api/oauth/passkey/authenticate/verify/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { users, passkeys } from "@/lib/db/schema"; +import { getWebAuthnChallenge, deleteWebAuthnChallenge } from "@/lib/redis"; +import { rpID, origin, base64UrlDecode } from "@/lib/webauthn/config"; +import { passkeyAuthVerifyRequestSchema } from "@/lib/validations/oauth"; +import { + validateNativeClient, + resolveRequestedScopes, +} from "@/lib/oauth/native-client"; +import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; + +// POST /api/oauth/passkey/authenticate/verify +// +// Native passkey sign-in completion: verifies the WebAuthn assertion against +// the Redis-stored challenge created by /options, looks up the matching user +// (via the credential id), and returns OAuth tokens. +// Response shape matches /api/oauth/token's authorization_code grant. +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "invalid_request", error_description: "Body must be JSON" }, + { status: 400 }, + ); + } + + const parsed = passkeyAuthVerifyRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "invalid_request", + error_description: parsed.error.issues[0]?.message, + }, + { status: 400 }, + ); + } + const data = parsed.data; + + const clientCheck = await validateNativeClient(data.client_id); + if (!clientCheck.ok) return clientCheck.response; + const client = clientCheck.client; + + const scopeCheck = resolveRequestedScopes(data.scope, client); + if (!scopeCheck.ok) return scopeCheck.response; + const requestedScopes = scopeCheck.scopes; + + const challengeData = await getWebAuthnChallenge(data.session_id); + if ( + !challengeData || + challengeData.type !== "native-authentication" || + challengeData.clientId !== data.client_id + ) { + return NextResponse.json( + { + error: "invalid_grant", + error_description: "No authentication challenge found", + }, + { status: 400 }, + ); + } + + const passkey = await db.query.passkeys.findFirst({ + where: eq(passkeys.id, data.credential.id), + }); + if (!passkey) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Passkey not found" }, + { status: 400 }, + ); + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, passkey.userId), + }); + if (!user) { + return NextResponse.json( + { error: "invalid_grant", error_description: "User not found" }, + { status: 400 }, + ); + } + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: data.credential as Parameters< + typeof verifyAuthenticationResponse + >[0]["response"], + expectedChallenge: challengeData.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: passkey.id, + publicKey: base64UrlDecode(passkey.publicKey), + counter: passkey.counter, + transports: passkey.transports + ? JSON.parse(passkey.transports) + : undefined, + }, + }); + } catch (error) { + console.error("Passkey verify error:", error); + return NextResponse.json( + { error: "invalid_grant", error_description: "Verification failed" }, + { status: 400 }, + ); + } + + if (!verification.verified) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Verification failed" }, + { status: 400 }, + ); + } + + // Update counter and last-used; clean up challenge. + await db + .update(passkeys) + .set({ + counter: verification.authenticationInfo.newCounter, + lastUsedAt: new Date(), + }) + .where(eq(passkeys.id, passkey.id)); + await deleteWebAuthnChallenge(data.session_id); + + const tokenResponse = await issueOAuthTokenResponse({ + user, + client, + scopes: requestedScopes, + }); + + return NextResponse.json(tokenResponse); +} diff --git a/app/api/oauth/passkey/register/options/route.ts b/app/api/oauth/passkey/register/options/route.ts new file mode 100644 index 0000000..d1dccfa --- /dev/null +++ b/app/api/oauth/passkey/register/options/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateRegistrationOptions } from "@simplewebauthn/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { + storeWebAuthnChallenge, + type WebAuthnChallengeData, +} from "@/lib/redis"; +import { getRegistrationOptions } from "@/lib/webauthn/config"; +import { passkeyRegisterOptionsRequestSchema } from "@/lib/validations/oauth"; +import { validateNativeClient } from "@/lib/oauth/native-client"; +import { checkSignUpAllowed } from "@/lib/settings/sign-up"; + +// POST /api/oauth/passkey/register/options +// +// Native passkey registration: returns WebAuthn registration options for a +// new user, plus a session id that ties this challenge to the pending +// signup. The user row is NOT created here — that happens in /verify after +// the attestation is verified. +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "invalid_request", error_description: "Body must be JSON" }, + { status: 400 }, + ); + } + + const parsed = passkeyRegisterOptionsRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "invalid_request", + error_description: parsed.error.issues[0]?.message, + }, + { status: 400 }, + ); + } + const data = parsed.data; + + const clientCheck = await validateNativeClient(data.client_id); + if (!clientCheck.ok) return clientCheck.response; + + const email = data.username.toLowerCase(); + + const signUpCheck = await checkSignUpAllowed(email); + if (!signUpCheck.allowed) { + return NextResponse.json( + { + error: "access_denied", + error_description: + signUpCheck.reason === "disabled" + ? "Sign-up is currently disabled" + : "Sign-up is restricted. Your email is not on the approved list", + }, + { status: 403 }, + ); + } + + const existing = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + if (existing) { + return NextResponse.json( + { + error: "user_exists", + error_description: "An account with this email already exists", + }, + { status: 409 }, + ); + } + + // Generate a pending user id now so the WebAuthn credential ties to it. + const pendingUserId = crypto.randomUUID(); + const displayName = data.name || email.split("@")[0]; + const sessionId = crypto.randomUUID(); + + const options = getRegistrationOptions(pendingUserId, email, displayName); + const registrationOptions = await generateRegistrationOptions(options); + + const challengeData: WebAuthnChallengeData = { + challenge: registrationOptions.challenge, + userId: pendingUserId, + type: "native-registration", + clientId: data.client_id, + pendingEmail: email, + pendingDisplayName: displayName, + createdAt: Date.now(), + }; + await storeWebAuthnChallenge(sessionId, challengeData); + + return NextResponse.json({ + ...registrationOptions, + sessionId, + }); +} diff --git a/app/api/oauth/passkey/register/verify/route.test.ts b/app/api/oauth/passkey/register/verify/route.test.ts new file mode 100644 index 0000000..6be44cf --- /dev/null +++ b/app/api/oauth/passkey/register/verify/route.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; + +const VALID_CLIENT = { + id: "macos-test-app", + clientType: "public" as const, + secret: null as string | null, + name: "macOS Test App", + description: null, + iconUrl: null, + redirectUris: JSON.stringify(["rxauthswift://callback"]), + allowedScopes: JSON.stringify(["openid", "read:profile", "read:email"]), + isFirstParty: true, + signInPermission: "all" as const, + permissions: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const PENDING_USER_ID = "pending-user-id"; +const PENDING_EMAIL = "newbie@example.com"; + +const VALID_CHALLENGE = { + challenge: "challenge-stub", + userId: PENDING_USER_ID, + type: "native-registration" as const, + clientId: VALID_CLIENT.id, + pendingEmail: PENDING_EMAIL, + pendingDisplayName: "Newbie", + createdAt: Date.now(), +}; + +const findClient = mock(); +const findExistingUser = mock(); +const getChallengeMock = mock(); +const deleteChallengeMock = mock(); +const verifyRegistrationResponseMock = mock(); +const signAccessTokenMock = mock(); +const signIdTokenMock = mock(); +const generateRefreshTokenMock = mock(); +const generateAvatarSeedMock = mock(); +const insertValues = mock(); +const transactionMock = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + oauthClients: { findFirst: findClient }, + users: { findFirst: findExistingUser }, + }, + insert: () => ({ values: insertValues }), + transaction: transactionMock, + }, +})); + +mock.module("@/lib/redis", () => ({ + getWebAuthnChallenge: getChallengeMock, + deleteWebAuthnChallenge: deleteChallengeMock, + storeWebAuthnChallenge: mock(), + getOAuthCode: mock(), + deleteOAuthCode: mock(), + storeOAuthCode: mock(), +})); + +mock.module("@simplewebauthn/server", () => ({ + verifyRegistrationResponse: verifyRegistrationResponseMock, + verifyAuthenticationResponse: mock(), + generateAuthenticationOptions: mock(), + generateRegistrationOptions: mock(), +})); + +mock.module("@/lib/oauth/jwt", () => ({ + signAccessToken: signAccessTokenMock, + signIdToken: signIdTokenMock, + generateRefreshToken: generateRefreshTokenMock, +})); + +mock.module("@/lib/identicon/generate", () => ({ + generateAvatarSeed: generateAvatarSeedMock, + generateIdenticon: mock(), + generateIdenticonDataUrl: mock(), +})); + +const { POST } = await import("./route"); + +const VALID_BODY = { + client_id: VALID_CLIENT.id, + session_id: "session-id", + credential: { + id: "new-cred-id", + rawId: "new-cred-id", + type: "public-key", + response: { + clientDataJSON: "abc", + attestationObject: "def", + transports: ["internal"], + }, + }, + scope: "openid read:email", +}; + +function makeRequest(body: Record): Request { + return new Request( + "https://auth.rxlab.app/api/oauth/passkey/register/verify", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/oauth/passkey/register/verify", () => { + beforeEach(() => { + findClient.mockReset(); + findExistingUser.mockReset(); + getChallengeMock.mockReset(); + deleteChallengeMock.mockReset(); + verifyRegistrationResponseMock.mockReset(); + signAccessTokenMock.mockReset(); + signIdTokenMock.mockReset(); + generateRefreshTokenMock.mockReset(); + generateAvatarSeedMock.mockReset(); + insertValues.mockReset(); + transactionMock.mockReset(); + + findClient.mockResolvedValue(VALID_CLIENT); + findExistingUser.mockResolvedValue(undefined); + getChallengeMock.mockResolvedValue(VALID_CHALLENGE); + deleteChallengeMock.mockResolvedValue(undefined); + verifyRegistrationResponseMock.mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: "new-cred-id", + publicKey: new Uint8Array([1, 2, 3]), + counter: 0, + }, + credentialDeviceType: "platform", + credentialBackedUp: true, + }, + }); + signAccessTokenMock.mockResolvedValue("access-token-stub"); + signIdTokenMock.mockResolvedValue("id-token-stub"); + generateRefreshTokenMock.mockReturnValue("refresh-token-stub"); + generateAvatarSeedMock.mockReturnValue("seed-stub"); + insertValues.mockResolvedValue(undefined); + transactionMock.mockImplementation(async (cb: (tx: unknown) => unknown) => { + const tx = { insert: () => ({ values: insertValues }) }; + await cb(tx); + }); + }); + + test("happy path: creates user + passkey and returns tokens", async () => { + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.access_token).toBe("access-token-stub"); + expect(body.refresh_token).toBe("refresh-token-stub"); + expect(body.id_token).toBe("id-token-stub"); + expect(body.token_type).toBe("Bearer"); + expect(body.scope).toBe("openid read:email"); + expect(transactionMock).toHaveBeenCalled(); + // 2 tx inserts + 1 refresh-token insert from issueOAuthTokenResponse + expect(insertValues).toHaveBeenCalledTimes(3); + expect(deleteChallengeMock).toHaveBeenCalledWith("session-id"); + }); + + test("invalid_client: rejects unknown client_id", async () => { + findClient.mockResolvedValue(undefined); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_client"); + }); + + test("unauthorized_client: rejects client with signInPermission != all", async () => { + findClient.mockResolvedValue({ + ...VALID_CLIENT, + signInPermission: "none", + }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("unauthorized_client"); + }); + + test("invalid_grant: missing challenge for session_id", async () => { + getChallengeMock.mockResolvedValue(null); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_grant: challenge type mismatch (browser registration)", async () => { + getChallengeMock.mockResolvedValue({ + ...VALID_CHALLENGE, + type: "registration", + }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_grant: WebAuthn registration verification fails", async () => { + verifyRegistrationResponseMock.mockResolvedValue({ verified: false }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("user_exists: email taken between options and verify", async () => { + findExistingUser.mockResolvedValue({ id: "x", email: PENDING_EMAIL }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe("user_exists"); + expect(deleteChallengeMock).toHaveBeenCalledWith("session-id"); + }); + + test("invalid_scope: scope outside client's allowed_scopes", async () => { + const res = await POST( + makeRequest({ ...VALID_BODY, scope: "openid admin:all" }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_scope"); + }); +}); diff --git a/app/api/oauth/passkey/register/verify/route.ts b/app/api/oauth/passkey/register/verify/route.ts new file mode 100644 index 0000000..e22a90c --- /dev/null +++ b/app/api/oauth/passkey/register/verify/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyRegistrationResponse } from "@simplewebauthn/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { users, passkeys } from "@/lib/db/schema"; +import { getWebAuthnChallenge, deleteWebAuthnChallenge } from "@/lib/redis"; +import { rpID, origin, base64UrlEncode } from "@/lib/webauthn/config"; +import { generateAvatarSeed } from "@/lib/identicon/generate"; +import { passkeyRegisterVerifyRequestSchema } from "@/lib/validations/oauth"; +import { + validateNativeClient, + resolveRequestedScopes, +} from "@/lib/oauth/native-client"; +import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; + +// POST /api/oauth/passkey/register/verify +// +// Native passkey registration completion: verifies the attestation against +// the pending-user challenge from /options, creates the user + passkey, and +// returns OAuth tokens. Mirrors /api/oauth/signup's E2E branch but uses a +// passkey as the credential instead of a password. +// +// The user is created already-verified — the WebAuthn challenge proves the +// caller controls a device, and there is no password to recover, so the +// email-verification gate is unnecessary for this flow. +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "invalid_request", error_description: "Body must be JSON" }, + { status: 400 }, + ); + } + + const parsed = passkeyRegisterVerifyRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "invalid_request", + error_description: parsed.error.issues[0]?.message, + }, + { status: 400 }, + ); + } + const data = parsed.data; + + const clientCheck = await validateNativeClient(data.client_id); + if (!clientCheck.ok) return clientCheck.response; + const client = clientCheck.client; + + const scopeCheck = resolveRequestedScopes(data.scope, client); + if (!scopeCheck.ok) return scopeCheck.response; + const requestedScopes = scopeCheck.scopes; + + const challengeData = await getWebAuthnChallenge(data.session_id); + if ( + !challengeData || + challengeData.type !== "native-registration" || + challengeData.clientId !== data.client_id || + !challengeData.pendingEmail || + !challengeData.userId + ) { + return NextResponse.json( + { + error: "invalid_grant", + error_description: "No registration challenge found", + }, + { status: 400 }, + ); + } + + // Re-check that the email wasn't taken between options and verify. + const existing = await db.query.users.findFirst({ + where: eq(users.email, challengeData.pendingEmail), + }); + if (existing) { + await deleteWebAuthnChallenge(data.session_id); + return NextResponse.json( + { + error: "user_exists", + error_description: "An account with this email already exists", + }, + { status: 409 }, + ); + } + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: data.credential as Parameters< + typeof verifyRegistrationResponse + >[0]["response"], + expectedChallenge: challengeData.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + }); + } catch (error) { + console.error("Passkey register verify error:", error); + return NextResponse.json( + { error: "invalid_grant", error_description: "Verification failed" }, + { status: 400 }, + ); + } + + if (!verification.verified || !verification.registrationInfo) { + return NextResponse.json( + { error: "invalid_grant", error_description: "Verification failed" }, + { status: 400 }, + ); + } + + const { + credential: cred, + credentialDeviceType, + credentialBackedUp, + } = verification.registrationInfo; + + const userId = challengeData.userId; + const email = challengeData.pendingEmail; + const displayName = challengeData.pendingDisplayName || email.split("@")[0]; + const avatarSeed = generateAvatarSeed(); + const now = new Date(); + + await db.transaction(async (tx) => { + await tx.insert(users).values({ + id: userId, + email, + passwordHash: null, + displayName, + avatarSeed, + emailVerified: true, + createdAt: now, + updatedAt: now, + }); + + await tx.insert(passkeys).values({ + id: cred.id, + userId, + name: "Passkey", + publicKey: base64UrlEncode(cred.publicKey), + counter: cred.counter, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + transports: + (data.credential.response as { transports?: string[] }).transports && + Array.isArray( + (data.credential.response as { transports?: string[] }).transports, + ) + ? JSON.stringify( + (data.credential.response as { transports: string[] }) + .transports, + ) + : null, + createdAt: now, + }); + }); + + await deleteWebAuthnChallenge(data.session_id); + + const tokenResponse = await issueOAuthTokenResponse({ + user: { + id: userId, + email, + emailVerified: true, + displayName, + username: null, + avatarSeed, + avatarUrl: null, + }, + client, + scopes: requestedScopes, + }); + + return NextResponse.json(tokenResponse, { status: 201 }); +} diff --git a/app/api/oauth/signup/route.ts b/app/api/oauth/signup/route.ts index da28ab2..654e3a9 100644 --- a/app/api/oauth/signup/route.ts +++ b/app/api/oauth/signup/route.ts @@ -3,16 +3,11 @@ import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { oauthClients, - oauthRefreshTokens, users, emailVerificationTokens, } from "@/lib/db/schema"; import { hashPassword } from "@/lib/auth/password"; -import { - signAccessToken, - signIdToken, - generateRefreshToken, -} from "@/lib/oauth/jwt"; +import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; import { signupRequestSchema } from "@/lib/validations/oauth"; import { checkSignUpAllowed } from "@/lib/settings/sign-up"; import { generateAvatarSeed } from "@/lib/identicon/generate"; @@ -205,51 +200,19 @@ export async function POST(request: NextRequest) { } // E2E branch only: issue tokens identical to the authorization_code grant. - const scopeString = requestedScopes.join(" "); - - const accessToken = await signAccessToken({ - sub: userId, - client_id: client.id, - scope: scopeString, - }); - - let idToken: string | undefined; - if (requestedScopes.includes("openid")) { - idToken = await signIdToken( - { - sub: userId, - email: requestedScopes.includes("email") ? email : undefined, - email_verified: requestedScopes.includes("email") ? true : undefined, - name: displayName, - picture: `${process.env.OAUTH_ISSUER_URL}/api/avatar/${avatarSeed}`, - auth_time: Math.floor(Date.now() / 1000), - }, - client.id, - ); - } - - const refreshToken = generateRefreshToken(); - const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - - await db.insert(oauthRefreshTokens).values({ - id: crypto.randomUUID(), - token: refreshToken, - userId, - clientId: client.id, - scopes: JSON.stringify(requestedScopes), - expiresAt: refreshExpiresAt, - createdAt: now, + const tokenResponse = await issueOAuthTokenResponse({ + user: { + id: userId, + email, + emailVerified: true, + displayName, + username: null, + avatarSeed, + avatarUrl: null, + }, + client, + scopes: requestedScopes, }); - return NextResponse.json( - { - access_token: accessToken, - token_type: "Bearer", - expires_in: 3600, - ...(idToken ? { id_token: idToken } : {}), - scope: scopeString, - refresh_token: refreshToken, - }, - { status: 201 }, - ); + return NextResponse.json(tokenResponse, { status: 201 }); } diff --git a/app/api/oauth/token/password-grant.test.ts b/app/api/oauth/token/password-grant.test.ts index 9e0b82d..e20391e 100644 --- a/app/api/oauth/token/password-grant.test.ts +++ b/app/api/oauth/token/password-grant.test.ts @@ -67,6 +67,10 @@ mock.module("@/lib/oauth/jwt", () => ({ mock.module("@/lib/redis", () => ({ getOAuthCode: mock(), deleteOAuthCode: mock(), + storeOAuthCode: mock(), + getWebAuthnChallenge: mock(), + deleteWebAuthnChallenge: mock(), + storeWebAuthnChallenge: mock(), })); const { POST } = await import("./route"); diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts index de42d10..928d7a8 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -14,6 +14,7 @@ import { signIdToken, generateRefreshToken, } from "@/lib/oauth/jwt"; +import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; import { parseBasicAuth } from "@/lib/oauth/basic-auth"; import { tokenRequestSchema } from "@/lib/validations/oauth"; import { eq, and, isNull, or, gt } from "drizzle-orm"; @@ -549,54 +550,11 @@ async function handlePasswordGrant( ); } - const scopeString = requestedScopes.join(" "); - - const accessToken = await signAccessToken({ - sub: user.id, - client_id: client.id, - scope: scopeString, + const tokenResponse = await issueOAuthTokenResponse({ + user, + client, + scopes: requestedScopes, }); - // Issue id_token when openid scope was granted - let idToken: string | undefined; - if (requestedScopes.includes("openid")) { - idToken = await signIdToken( - { - sub: user.id, - email: requestedScopes.includes("email") ? user.email : undefined, - email_verified: requestedScopes.includes("email") - ? (user.emailVerified ?? false) - : undefined, - name: user.displayName ?? undefined, - preferred_username: user.username ?? undefined, - picture: - user.avatarUrl || - `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, - auth_time: Math.floor(Date.now() / 1000), - }, - client.id, - ); - } - - const refreshToken = generateRefreshToken(); - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - - await db.insert(oauthRefreshTokens).values({ - id: crypto.randomUUID(), - token: refreshToken, - userId: user.id, - clientId: client.id, - scopes: JSON.stringify(requestedScopes), - expiresAt, - createdAt: new Date(), - }); - - return NextResponse.json({ - access_token: accessToken, - token_type: "Bearer", - expires_in: 3600, - ...(idToken ? { id_token: idToken } : {}), - scope: scopeString, - refresh_token: refreshToken, - }); + return NextResponse.json(tokenResponse); } diff --git a/lib/oauth/issue-tokens.ts b/lib/oauth/issue-tokens.ts new file mode 100644 index 0000000..18fbab6 --- /dev/null +++ b/lib/oauth/issue-tokens.ts @@ -0,0 +1,93 @@ +import { db } from "@/lib/db"; +import { oauthRefreshTokens } from "@/lib/db/schema"; +import { + signAccessToken, + signIdToken, + generateRefreshToken, +} from "@/lib/oauth/jwt"; + +const ACCESS_TOKEN_EXPIRES_IN = 3600; +const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +export interface TokenSubject { + id: string; + email: string; + emailVerified?: boolean | null; + displayName?: string | null; + username?: string | null; + avatarSeed?: string | null; + avatarUrl?: string | null; +} + +export interface TokenClient { + id: string; +} + +export interface OAuthTokenResponse { + access_token: string; + token_type: "Bearer"; + expires_in: number; + refresh_token: string; + scope: string; + id_token?: string; +} + +// Single canonical token-issuance path used by every non-authorization_code +// grant (password, signup, passkey native sign-in, passkey native sign-up). +// Inserts a refresh-token row, signs an access token, and conditionally an +// id_token when `openid` is granted. Shape matches /api/oauth/token's +// authorization_code response. +export async function issueOAuthTokenResponse(params: { + user: TokenSubject; + client: TokenClient; + scopes: string[]; +}): Promise { + const { user, client, scopes } = params; + const scopeString = scopes.join(" "); + + const accessToken = await signAccessToken({ + sub: user.id, + client_id: client.id, + scope: scopeString, + }); + + let idToken: string | undefined; + if (scopes.includes("openid")) { + idToken = await signIdToken( + { + sub: user.id, + email: scopes.includes("email") ? user.email : undefined, + email_verified: scopes.includes("email") + ? (user.emailVerified ?? false) + : undefined, + name: user.displayName ?? undefined, + preferred_username: user.username ?? undefined, + picture: + user.avatarUrl || + `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, + auth_time: Math.floor(Date.now() / 1000), + }, + client.id, + ); + } + + const refreshToken = generateRefreshToken(); + await db.insert(oauthRefreshTokens).values({ + id: crypto.randomUUID(), + token: refreshToken, + userId: user.id, + clientId: client.id, + scopes: JSON.stringify(scopes), + expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS), + createdAt: new Date(), + }); + + return { + access_token: accessToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_EXPIRES_IN, + ...(idToken ? { id_token: idToken } : {}), + scope: scopeString, + refresh_token: refreshToken, + }; +} diff --git a/lib/oauth/native-client.ts b/lib/oauth/native-client.ts new file mode 100644 index 0000000..7d5c24f --- /dev/null +++ b/lib/oauth/native-client.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { oauthClients } from "@/lib/db/schema"; + +type Client = typeof oauthClients.$inferSelect; + +// Result of validating that a client is allowed to use native OAuth flows +// (password grant, native signup, native passkey routes). +// +// Mirrors the gate used in /api/oauth/token's password branch: first-party, +// open sign-in clients only. Returning a Response means the caller must +// short-circuit; returning a `client` means the request is allowed. +export type NativeClientCheck = + | { ok: true; client: Client } + | { ok: false; response: NextResponse }; + +export async function validateNativeClient( + clientId: string, +): Promise { + const client = await db.query.oauthClients.findFirst({ + where: eq(oauthClients.id, clientId), + }); + if (!client) { + return { + ok: false, + response: NextResponse.json( + { error: "invalid_client", error_description: "Client not found" }, + { status: 401 }, + ), + }; + } + if (client.signInPermission !== "all") { + return { + ok: false, + response: NextResponse.json( + { + error: "unauthorized_client", + error_description: + "Native flows are only available for first-party clients", + }, + { status: 400 }, + ), + }; + } + return { ok: true, client }; +} + +// Resolve a requested-scope string against a client's allowed_scopes. +// Returns either the resolved scope array or a 400 response. +export function resolveRequestedScopes( + scope: string | undefined, + client: Client, +): { ok: true; scopes: string[] } | { ok: false; response: NextResponse } { + const allowedScopes: string[] = JSON.parse(client.allowedScopes); + if (!scope) { + return { ok: true, scopes: allowedScopes }; + } + const requestedScopes = scope.split(" ").filter(Boolean); + const invalid = requestedScopes.filter((s) => !allowedScopes.includes(s)); + if (invalid.length > 0) { + return { + ok: false, + response: NextResponse.json( + { + error: "invalid_scope", + error_description: `Requested scope(s) not allowed: ${invalid.join(", ")}`, + }, + { status: 400 }, + ), + }; + } + return { ok: true, scopes: requestedScopes }; +} diff --git a/lib/redis/index.ts b/lib/redis/index.ts index b298efb..cc891fd 100644 --- a/lib/redis/index.ts +++ b/lib/redis/index.ts @@ -44,8 +44,17 @@ export interface OAuthCodeData { export interface WebAuthnChallengeData { challenge: string; userId?: string; - type: "registration" | "authentication"; + type: + | "registration" + | "authentication" + | "native-registration" + | "native-authentication"; createdAt: number; + // Native (OAuth) passkey flows pin the challenge to a specific client. + clientId?: string; + // Native registration only: pending user-to-be-created + pendingEmail?: string; + pendingDisplayName?: string; } // Helper functions diff --git a/lib/validations/oauth.test.ts b/lib/validations/oauth.test.ts index 4be100b..6b058a1 100644 --- a/lib/validations/oauth.test.ts +++ b/lib/validations/oauth.test.ts @@ -2,10 +2,37 @@ import { describe, expect, test } from "bun:test"; import { tokenRequestSchema, signupRequestSchema, + passkeyAuthOptionsRequestSchema, + passkeyAuthVerifyRequestSchema, + passkeyRegisterOptionsRequestSchema, + passkeyRegisterVerifyRequestSchema, parseScopes, validateScopes, } from "./oauth"; +const VALID_ASSERTION = { + id: "cred-id-base64url", + rawId: "cred-id-base64url", + type: "public-key" as const, + response: { + clientDataJSON: "abc", + authenticatorData: "def", + signature: "ghi", + userHandle: "jkl", + }, +}; + +const VALID_ATTESTATION = { + id: "cred-id-base64url", + rawId: "cred-id-base64url", + type: "public-key" as const, + response: { + clientDataJSON: "abc", + attestationObject: "def", + transports: ["internal"], + }, +}; + describe("tokenRequestSchema - client_credentials", () => { test("should accept valid client_credentials request", () => { const result = tokenRequestSchema.safeParse({ @@ -281,6 +308,110 @@ describe("signupRequestSchema", () => { }); }); +describe("passkeyAuthOptionsRequestSchema", () => { + test("accepts minimal body", () => { + const result = passkeyAuthOptionsRequestSchema.safeParse({ + client_id: "macos-test-app", + }); + expect(result.success).toBe(true); + }); + + test("accepts optional username (email)", () => { + const result = passkeyAuthOptionsRequestSchema.safeParse({ + client_id: "macos-test-app", + username: "user@example.com", + }); + expect(result.success).toBe(true); + }); + + test("rejects non-email username", () => { + const result = passkeyAuthOptionsRequestSchema.safeParse({ + client_id: "macos-test-app", + username: "not-an-email", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing client_id", () => { + const result = passkeyAuthOptionsRequestSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe("passkeyAuthVerifyRequestSchema", () => { + test("accepts valid request", () => { + const result = passkeyAuthVerifyRequestSchema.safeParse({ + client_id: "macos-test-app", + session_id: "session-id", + credential: VALID_ASSERTION, + scope: "openid", + }); + expect(result.success).toBe(true); + }); + + test("rejects missing session_id", () => { + const result = passkeyAuthVerifyRequestSchema.safeParse({ + client_id: "macos-test-app", + credential: VALID_ASSERTION, + }); + expect(result.success).toBe(false); + }); + + test("rejects credential missing assertion fields", () => { + const result = passkeyAuthVerifyRequestSchema.safeParse({ + client_id: "macos-test-app", + session_id: "session-id", + credential: { id: "x", rawId: "x", type: "public-key", response: {} }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("passkeyRegisterOptionsRequestSchema", () => { + test("accepts minimal body", () => { + const result = passkeyRegisterOptionsRequestSchema.safeParse({ + client_id: "macos-test-app", + username: "user@example.com", + }); + expect(result.success).toBe(true); + }); + + test("rejects non-email username", () => { + const result = passkeyRegisterOptionsRequestSchema.safeParse({ + client_id: "macos-test-app", + username: "not-an-email", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing client_id", () => { + const result = passkeyRegisterOptionsRequestSchema.safeParse({ + username: "user@example.com", + }); + expect(result.success).toBe(false); + }); +}); + +describe("passkeyRegisterVerifyRequestSchema", () => { + test("accepts valid request", () => { + const result = passkeyRegisterVerifyRequestSchema.safeParse({ + client_id: "macos-test-app", + session_id: "session-id", + credential: VALID_ATTESTATION, + }); + expect(result.success).toBe(true); + }); + + test("rejects credential missing attestation fields", () => { + const result = passkeyRegisterVerifyRequestSchema.safeParse({ + client_id: "macos-test-app", + session_id: "session-id", + credential: { id: "x", rawId: "x", type: "public-key", response: {} }, + }); + expect(result.success).toBe(false); + }); +}); + describe("parseScopes", () => { test("should parse space-separated scopes", () => { const scopes = parseScopes("openid read:profile read:email"); diff --git a/lib/validations/oauth.ts b/lib/validations/oauth.ts index 3b98e38..0e737f0 100644 --- a/lib/validations/oauth.ts +++ b/lib/validations/oauth.ts @@ -64,6 +64,85 @@ export const signupRequestSchema = z.object({ export type SignupRequest = z.infer; +// WebAuthn assertion JSON (sign-in). Kept loose; @simplewebauthn does the +// real structural validation against the stored challenge. +export const passkeyAssertionSchema = z + .object({ + id: z.string().min(1), + rawId: z.string().min(1), + type: z.literal("public-key"), + response: z.object({ + clientDataJSON: z.string().min(1), + authenticatorData: z.string().min(1), + signature: z.string().min(1), + userHandle: z.string().nullish(), + }), + clientExtensionResults: z.record(z.string(), z.unknown()).optional(), + authenticatorAttachment: z.string().optional(), + }) + .passthrough(); + +// WebAuthn attestation JSON (registration). +export const passkeyAttestationSchema = z + .object({ + id: z.string().min(1), + rawId: z.string().min(1), + type: z.literal("public-key"), + response: z.object({ + clientDataJSON: z.string().min(1), + attestationObject: z.string().min(1), + transports: z.array(z.string()).optional(), + publicKeyAlgorithm: z.number().optional(), + publicKey: z.string().optional(), + authenticatorData: z.string().optional(), + }), + clientExtensionResults: z.record(z.string(), z.unknown()).optional(), + authenticatorAttachment: z.string().optional(), + }) + .passthrough(); + +// POST /api/oauth/passkey/authenticate/options +export const passkeyAuthOptionsRequestSchema = z.object({ + client_id: z.string().min(1, "client_id is required"), + username: z.string().email().optional(), +}); + +// POST /api/oauth/passkey/authenticate/verify +export const passkeyAuthVerifyRequestSchema = z.object({ + client_id: z.string().min(1, "client_id is required"), + session_id: z.string().min(1, "session_id is required"), + credential: passkeyAssertionSchema, + scope: z.string().optional(), +}); + +// POST /api/oauth/passkey/register/options +export const passkeyRegisterOptionsRequestSchema = z.object({ + client_id: z.string().min(1, "client_id is required"), + username: z.string().email("username must be a valid email address"), + name: z.string().min(1).max(64).optional(), +}); + +// POST /api/oauth/passkey/register/verify +export const passkeyRegisterVerifyRequestSchema = z.object({ + client_id: z.string().min(1, "client_id is required"), + session_id: z.string().min(1, "session_id is required"), + credential: passkeyAttestationSchema, + scope: z.string().optional(), +}); + +export type PasskeyAuthOptionsRequest = z.infer< + typeof passkeyAuthOptionsRequestSchema +>; +export type PasskeyAuthVerifyRequest = z.infer< + typeof passkeyAuthVerifyRequestSchema +>; +export type PasskeyRegisterOptionsRequest = z.infer< + typeof passkeyRegisterOptionsRequestSchema +>; +export type PasskeyRegisterVerifyRequest = z.infer< + typeof passkeyRegisterVerifyRequestSchema +>; + export const revokeRequestSchema = z.object({ token: z.string().min(1, "token is required"), token_type_hint: z.enum(["access_token", "refresh_token"]).optional(),