From d39b509f1e9967b96207686abc56ce13c7629155 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 21:41:47 +0800 Subject: [PATCH] feat: add sign in schema for ui --- app/api/auth/ui-schema/signin/route.test.ts | 105 +++++++ app/api/auth/ui-schema/signin/route.ts | 35 +++ app/api/auth/ui-schema/signup/route.test.ts | 135 +++++++++ app/api/auth/ui-schema/signup/route.ts | 52 ++++ .../account-creation/options/route.test.ts | 152 ++++++++++ .../passkey/account-creation/options/route.ts | 84 ++++++ .../account-creation/verify/route.test.ts | 279 ++++++++++++++++++ .../passkey/account-creation/verify/route.ts | 208 +++++++++++++ .../passkey/upgrade/options/route.test.ts | 158 ++++++++++ .../oauth/passkey/upgrade/options/route.ts | 94 ++++++ .../passkey/upgrade/verify/route.test.ts | 228 ++++++++++++++ app/api/oauth/passkey/upgrade/verify/route.ts | 141 +++++++++ app/api/oauth/token/password-grant.test.ts | 1 + lib/oauth/bearer.ts | 49 +++ lib/oauth/contact-identifier.ts | 66 +++++ lib/redis/index.ts | 4 +- lib/ui-schema/build.test.ts | 169 +++++++++++ lib/ui-schema/build.ts | 223 ++++++++++++++ lib/validations/oauth.ts | 51 ++++ 19 files changed, 2233 insertions(+), 1 deletion(-) create mode 100644 app/api/auth/ui-schema/signin/route.test.ts create mode 100644 app/api/auth/ui-schema/signin/route.ts create mode 100644 app/api/auth/ui-schema/signup/route.test.ts create mode 100644 app/api/auth/ui-schema/signup/route.ts create mode 100644 app/api/oauth/passkey/account-creation/options/route.test.ts create mode 100644 app/api/oauth/passkey/account-creation/options/route.ts create mode 100644 app/api/oauth/passkey/account-creation/verify/route.test.ts create mode 100644 app/api/oauth/passkey/account-creation/verify/route.ts create mode 100644 app/api/oauth/passkey/upgrade/options/route.test.ts create mode 100644 app/api/oauth/passkey/upgrade/options/route.ts create mode 100644 app/api/oauth/passkey/upgrade/verify/route.test.ts create mode 100644 app/api/oauth/passkey/upgrade/verify/route.ts create mode 100644 lib/oauth/bearer.ts create mode 100644 lib/oauth/contact-identifier.ts create mode 100644 lib/ui-schema/build.test.ts create mode 100644 lib/ui-schema/build.ts diff --git a/app/api/auth/ui-schema/signin/route.test.ts b/app/api/auth/ui-schema/signin/route.test.ts new file mode 100644 index 0000000..e5d63de --- /dev/null +++ b/app/api/auth/ui-schema/signin/route.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; + +const FIRST_PARTY_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", "email", "profile"]), + isFirstParty: true, + signInPermission: "all" as const, + permissions: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const findClient = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + oauthClients: { findFirst: findClient }, + }, + }, +})); + +const { GET } = await import("./route"); + +function makeRequest(search: string = ""): Request { + const url = `https://auth.rxlab.app/api/auth/ui-schema/signin${search ? `?${search}` : ""}`; + return new Request(url); +} + +describe("GET /api/auth/ui-schema/signin", () => { + beforeEach(() => { + findClient.mockReset(); + }); + + test("first-party client returns password + passkey methods", async () => { + findClient.mockResolvedValue(FIRST_PARTY_CLIENT); + + const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.flow).toBe("signin"); + expect(body.title).toBe("Sign in to macOS Test App"); + expect(body.submitLabel).toBe("Sign in"); + + const methodIds = body.supportedMethods.map((m: { id: string }) => m.id); + expect(methodIds).toEqual(["password", "passkey"]); + + const fieldKeys = body.fields.map((f: { key: string }) => f.key); + expect(fieldKeys).toEqual(["email", "password"]); + }); + + test("unknown client_id returns 404 invalid_client", async () => { + findClient.mockResolvedValue(undefined); + + const res = await GET(makeRequest("client_id=ghost") as never); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("invalid_client"); + }); + + test("no client_id returns default schema (no methods)", async () => { + const res = await GET(makeRequest() as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.title).toBe("Sign in to RxLab"); + expect(body.supportedMethods).toEqual([]); + // findClient must not be invoked when client_id is absent. + expect(findClient).not.toHaveBeenCalled(); + }); + + test("response shape is stable", async () => { + findClient.mockResolvedValue(FIRST_PARTY_CLIENT); + const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never); + const body = await res.json(); + expect(Object.keys(body).sort()).toEqual([ + "fields", + "flow", + "links", + "submitLabel", + "supportedMethods", + "title", + ]); + for (const field of body.fields) { + expect(Object.keys(field).sort()).toEqual( + [ + "autocomplete", + "isPassword", + "key", + "label", + "placeholder", + "required", + "type", + "validation", + ].sort(), + ); + } + }); +}); diff --git a/app/api/auth/ui-schema/signin/route.ts b/app/api/auth/ui-schema/signin/route.ts new file mode 100644 index 0000000..2e77b0b --- /dev/null +++ b/app/api/auth/ui-schema/signin/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { oauthClients } from "@/lib/db/schema"; +import { buildSigninSchema, type OAuthClientLite } from "@/lib/ui-schema/build"; + +// GET /api/auth/ui-schema/signin?client_id= +// +// Public endpoint. Returns a JSON description of the sign-in form so native +// clients (e.g. RxAuthSwift on macOS) can render the UI without re-shipping. +// `client_id` is optional; if omitted we return a sane default schema. +// Unknown `client_id` → 404. +export async function GET(request: NextRequest) { + const clientId = new URL(request.url).searchParams.get("client_id"); + + let client: OAuthClientLite | null = null; + if (clientId) { + const row = await db.query.oauthClients.findFirst({ + where: eq(oauthClients.id, clientId), + }); + if (!row) { + return NextResponse.json( + { error: "invalid_client", error_description: "Client not found" }, + { status: 404 }, + ); + } + client = { + id: row.id, + name: row.name, + signInPermission: row.signInPermission, + }; + } + + return NextResponse.json(buildSigninSchema({ client })); +} diff --git a/app/api/auth/ui-schema/signup/route.test.ts b/app/api/auth/ui-schema/signup/route.test.ts new file mode 100644 index 0000000..383e58a --- /dev/null +++ b/app/api/auth/ui-schema/signup/route.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; + +const FIRST_PARTY_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", "email", "profile"]), + isFirstParty: true, + signInPermission: "all" as const, + permissions: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const findClient = mock(); +const getSignUpStatusMock = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + oauthClients: { findFirst: findClient }, + }, + }, +})); + +mock.module("@/lib/settings/sign-up", () => ({ + getSignUpStatus: getSignUpStatusMock, +})); + +const { GET } = await import("./route"); + +function makeRequest(search: string = ""): Request { + const url = `https://auth.rxlab.app/api/auth/ui-schema/signup${search ? `?${search}` : ""}`; + return new Request(url); +} + +describe("GET /api/auth/ui-schema/signup", () => { + beforeEach(() => { + findClient.mockReset(); + getSignUpStatusMock.mockReset(); + getSignUpStatusMock.mockResolvedValue({ + publicSignUpEnabled: true, + whitelistEnabled: false, + isCompletelyDisabled: false, + }); + delete process.env.UI_SCHEMA_PASSKEY_ACCOUNT_CREATION; + }); + + test("sign-up allowed returns password + passkey_account_creation by default (legacy passkey suppressed)", async () => { + findClient.mockResolvedValue(FIRST_PARTY_CLIENT); + + const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body.flow).toBe("signup"); + expect(body.title).toBe("Create your macOS Test App account"); + expect(body.submitLabel).toBe("Create account"); + + const methodIds = body.supportedMethods.map((m: { id: string }) => m.id); + expect(methodIds).toEqual(["password", "passkey_account_creation"]); + expect(methodIds).not.toContain("passkey"); + + const fieldKeys = body.fields.map((f: { key: string }) => f.key); + expect(fieldKeys).toEqual(["email", "password", "name"]); + }); + + test("UI_SCHEMA_PASSKEY_ACCOUNT_CREATION=false omits passkey_account_creation", async () => { + process.env.UI_SCHEMA_PASSKEY_ACCOUNT_CREATION = "false"; + findClient.mockResolvedValue(FIRST_PARTY_CLIENT); + + const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never); + const body = await res.json(); + const methodIds = body.supportedMethods.map((m: { id: string }) => m.id); + expect(methodIds).toEqual(["password", "passkey"]); + }); + + test("sign-up disabled omits all methods", async () => { + findClient.mockResolvedValue(FIRST_PARTY_CLIENT); + getSignUpStatusMock.mockResolvedValue({ + publicSignUpEnabled: false, + whitelistEnabled: false, + isCompletelyDisabled: true, + }); + + const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.supportedMethods).toEqual([]); + // Fields still present so client can render the form layout for an + // "invite-only / disabled" empty state. + const fieldKeys = body.fields.map((f: { key: string }) => f.key); + expect(fieldKeys).toEqual(["email", "password", "name"]); + }); + + test("whitelist-only mode still treats sign-up as form-level allowed", async () => { + findClient.mockResolvedValue(FIRST_PARTY_CLIENT); + getSignUpStatusMock.mockResolvedValue({ + publicSignUpEnabled: false, + whitelistEnabled: true, + isCompletelyDisabled: false, + }); + + const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never); + const body = await res.json(); + const methodIds = body.supportedMethods.map((m: { id: string }) => m.id); + expect(methodIds).toEqual(["password", "passkey_account_creation"]); + }); + + test("unknown client_id returns 404 invalid_client", async () => { + findClient.mockResolvedValue(undefined); + const res = await GET(makeRequest("client_id=ghost") as never); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("invalid_client"); + }); + + test("response shape is stable", async () => { + findClient.mockResolvedValue(FIRST_PARTY_CLIENT); + const res = await GET(makeRequest(`client_id=${FIRST_PARTY_CLIENT.id}`) as never); + const body = await res.json(); + expect(Object.keys(body).sort()).toEqual([ + "fields", + "flow", + "links", + "submitLabel", + "supportedMethods", + "title", + ]); + }); +}); diff --git a/app/api/auth/ui-schema/signup/route.ts b/app/api/auth/ui-schema/signup/route.ts new file mode 100644 index 0000000..5ad468e --- /dev/null +++ b/app/api/auth/ui-schema/signup/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { oauthClients } from "@/lib/db/schema"; +import { getSignUpStatus } from "@/lib/settings/sign-up"; +import { buildSignupSchema, type OAuthClientLite } from "@/lib/ui-schema/build"; + +// GET /api/auth/ui-schema/signup?client_id= +// +// Public endpoint. Returns a JSON description of the sign-up form. Mirrors +// /signin but the supportedMethods list is gated on the global sign-up +// settings (see lib/settings/sign-up.ts): when sign-up is fully disabled the +// methods list is empty so the client can render its own "sign-up closed" UI +// without hard-coding the rule. +export async function GET(request: NextRequest) { + const clientId = new URL(request.url).searchParams.get("client_id"); + + let client: OAuthClientLite | null = null; + if (clientId) { + const row = await db.query.oauthClients.findFirst({ + where: eq(oauthClients.id, clientId), + }); + if (!row) { + return NextResponse.json( + { error: "invalid_client", error_description: "Client not found" }, + { status: 404 }, + ); + } + client = { + id: row.id, + name: row.name, + signInPermission: row.signInPermission, + }; + } + + const status = await getSignUpStatus(); + // Without a candidate email we can't evaluate the whitelist, so treat + // whitelist-enabled as "sign-up is open at the form level" — the actual + // gate runs at POST /api/oauth/signup. + const signUpAllowed = !status.isCompletelyDisabled; + + // The iOS 26 / macOS 26 system-sheet account-creation endpoints + // (POST /api/oauth/passkey/account-creation/{options,verify}) are always + // available — feature-gate at the schema level so non-Apple clients that + // never call them simply ignore the entry. + const accountCreationEnabled = + process.env.UI_SCHEMA_PASSKEY_ACCOUNT_CREATION !== "false"; + + return NextResponse.json( + buildSignupSchema({ client, signUpAllowed, accountCreationEnabled }), + ); +} diff --git a/app/api/oauth/passkey/account-creation/options/route.test.ts b/app/api/oauth/passkey/account-creation/options/route.test.ts new file mode 100644 index 0000000..1e4921a --- /dev/null +++ b/app/api/oauth/passkey/account-creation/options/route.test.ts @@ -0,0 +1,152 @@ +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 findClient = mock(); +const storeChallengeMock = mock(); +const checkSignUpAllowedMock = mock(); +const generateRegistrationOptionsMock = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + oauthClients: { findFirst: findClient }, + }, + }, +})); + +mock.module("@/lib/redis", () => ({ + storeWebAuthnChallenge: storeChallengeMock, + getWebAuthnChallenge: mock(), + deleteWebAuthnChallenge: mock(), + getOAuthCode: mock(), + deleteOAuthCode: mock(), + storeOAuthCode: mock(), +})); + +mock.module("@simplewebauthn/server", () => ({ + generateRegistrationOptions: generateRegistrationOptionsMock, + generateAuthenticationOptions: mock(), + verifyAuthenticationResponse: mock(), + verifyRegistrationResponse: mock(), +})); + +mock.module("@/lib/settings/sign-up", () => ({ + checkSignUpAllowed: checkSignUpAllowedMock, +})); + +const { POST } = await import("./route"); + +function makeRequest(body: Record): Request { + return new Request( + "https://auth.rxlab.app/api/oauth/passkey/account-creation/options", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/oauth/passkey/account-creation/options", () => { + beforeEach(() => { + findClient.mockReset(); + storeChallengeMock.mockReset(); + checkSignUpAllowedMock.mockReset(); + generateRegistrationOptionsMock.mockReset(); + + findClient.mockResolvedValue(VALID_CLIENT); + storeChallengeMock.mockResolvedValue(undefined); + checkSignUpAllowedMock.mockResolvedValue({ allowed: true }); + generateRegistrationOptionsMock.mockResolvedValue({ + challenge: "challenge-stub", + rp: { id: "rxlab.app", name: "RxLab Auth" }, + user: { id: "uid", name: "", displayName: "" }, + pubKeyCredParams: [], + attestation: "none", + }); + }); + + test("happy path: returns options + sessionId, stores passkey-account-creation challenge with no identifier yet", async () => { + const res = await POST( + makeRequest({ + client_id: VALID_CLIENT.id, + redirect_uri: "rxauthswift://callback", + }) as never, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.sessionId).toBe("string"); + expect(body.challenge).toBe("challenge-stub"); + + expect(storeChallengeMock).toHaveBeenCalledTimes(1); + const stored = storeChallengeMock.mock.calls[0][1]; + expect(stored.type).toBe("passkey-account-creation"); + expect(stored.clientId).toBe(VALID_CLIENT.id); + expect(stored.redirectUri).toBe("rxauthswift://callback"); + expect(typeof stored.userId).toBe("string"); + expect(stored.pendingEmail).toBeUndefined(); + }); + + test("invalid_request: missing redirect_uri", async () => { + const res = await POST( + makeRequest({ client_id: VALID_CLIENT.id }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_request"); + }); + + test("invalid_request: redirect_uri not in client's allow-list", async () => { + const res = await POST( + makeRequest({ + client_id: VALID_CLIENT.id, + redirect_uri: "some://other-callback", + }) as never, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_request"); + expect(body.error_description).toBe("Invalid redirect_uri for client"); + }); + + test("invalid_client: unknown client_id", async () => { + findClient.mockResolvedValue(undefined); + const res = await POST( + makeRequest({ + client_id: "ghost", + redirect_uri: "rxauthswift://callback", + }) as never, + ); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_client"); + }); + + test("access_denied: global sign-up disabled", async () => { + checkSignUpAllowedMock.mockResolvedValue({ + allowed: false, + reason: "disabled", + }); + const res = await POST( + makeRequest({ + client_id: VALID_CLIENT.id, + redirect_uri: "rxauthswift://callback", + }) as never, + ); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe("access_denied"); + }); +}); diff --git a/app/api/oauth/passkey/account-creation/options/route.ts b/app/api/oauth/passkey/account-creation/options/route.ts new file mode 100644 index 0000000..36e63ea --- /dev/null +++ b/app/api/oauth/passkey/account-creation/options/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateRegistrationOptions } from "@simplewebauthn/server"; +import { + storeWebAuthnChallenge, + type WebAuthnChallengeData, +} from "@/lib/redis"; +import { getRegistrationOptions } from "@/lib/webauthn/config"; +import { passkeyAccountCreationOptionsRequestSchema } from "@/lib/validations/oauth"; +import { validateClientRedirect } from "@/lib/oauth/native-client"; +import { checkSignUpAllowed } from "@/lib/settings/sign-up"; + +// POST /api/oauth/passkey/account-creation/options +// +// iOS 26 / macOS 26 ASAuthorizationAccountCreationProvider: the OS sheet +// hasn't yet asked iCloud for the user's contact identifier, so we don't +// know who is signing up. We mint a placeholder userId and return +// WebAuthn registration options bound to it. The /verify step receives +// the actual contact_identifier (email or phone) from the system sheet +// and creates the user row. +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 = passkeyAccountCreationOptionsRequestSchema.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 validateClientRedirect({ + clientId: data.client_id, + redirectUri: data.redirect_uri, + }); + if (!clientCheck.ok) return clientCheck.response; + + // Global sign-up gate: respect the same disabled/whitelist controls as + // the other native signup routes. checkSignUpAllowed only refuses when + // global sign-up is disabled (no whitelist match is fine — there's no + // identifier to check yet; whitelist enforcement defers to /verify). + const status = await checkSignUpAllowed(""); + if (!status.allowed && status.reason === "disabled") { + return NextResponse.json( + { + error: "access_denied", + error_description: "Sign-up is currently disabled", + }, + { status: 403 }, + ); + } + + const pendingUserId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + + const options = getRegistrationOptions(pendingUserId, "", ""); + const registrationOptions = await generateRegistrationOptions(options); + + const challengeData: WebAuthnChallengeData = { + challenge: registrationOptions.challenge, + userId: pendingUserId, + type: "passkey-account-creation", + clientId: data.client_id, + redirectUri: data.redirect_uri, + createdAt: Date.now(), + }; + await storeWebAuthnChallenge(sessionId, challengeData); + + return NextResponse.json({ + ...registrationOptions, + sessionId, + }); +} diff --git a/app/api/oauth/passkey/account-creation/verify/route.test.ts b/app/api/oauth/passkey/account-creation/verify/route.test.ts new file mode 100644 index 0000000..dbc6e44 --- /dev/null +++ b/app/api/oauth/passkey/account-creation/verify/route.test.ts @@ -0,0 +1,279 @@ +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 VALID_REDIRECT_URI = "rxauthswift://callback"; + +const VALID_CHALLENGE = { + challenge: "challenge-stub", + userId: PENDING_USER_ID, + type: "passkey-account-creation" as const, + clientId: VALID_CLIENT.id, + redirectUri: VALID_REDIRECT_URI, + createdAt: Date.now(), +}; + +const findClient = mock(); +const findExistingUser = mock(); +const getChallengeMock = mock(); +const deleteChallengeMock = mock(); +const checkSignUpAllowedMock = 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, + verifyAccessToken: mock(), +})); + +mock.module("@/lib/identicon/generate", () => ({ + generateAvatarSeed: generateAvatarSeedMock, + generateIdenticon: mock(), + generateIdenticonDataUrl: mock(), +})); + +mock.module("@/lib/settings/sign-up", () => ({ + checkSignUpAllowed: checkSignUpAllowedMock, +})); + +const { POST } = await import("./route"); + +const CREDENTIAL = { + id: "new-cred-id", + rawId: "new-cred-id", + type: "public-key", + response: { + clientDataJSON: "abc", + attestationObject: "def", + transports: ["internal"], + }, +}; + +const VALID_EMAIL_BODY = { + client_id: VALID_CLIENT.id, + session_id: "session-id", + credential: CREDENTIAL, + contact_identifier: "newbie@example.com", + contact_identifier_type: "email", + name: "Newbie", + scope: "openid read:email", +}; + +const VALID_PHONE_BODY = { + client_id: VALID_CLIENT.id, + session_id: "session-id", + credential: CREDENTIAL, + contact_identifier: "+1 (415) 555-0100", + contact_identifier_type: "phone_number", + name: "Newbie", + scope: "openid", +}; + +function makeRequest(body: Record): Request { + return new Request( + "https://auth.rxlab.app/api/oauth/passkey/account-creation/verify", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/oauth/passkey/account-creation/verify", () => { + beforeEach(() => { + findClient.mockReset(); + findExistingUser.mockReset(); + getChallengeMock.mockReset(); + deleteChallengeMock.mockReset(); + checkSignUpAllowedMock.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); + checkSignUpAllowedMock.mockResolvedValue({ allowed: true }); + 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 (email): creates user with verified email + passkey, returns tokens", async () => { + const res = await POST(makeRequest(VALID_EMAIL_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.scope).toBe("openid read:email"); + + expect(transactionMock).toHaveBeenCalled(); + // 2 tx inserts (user + passkey) + 1 refresh-token insert. + expect(insertValues).toHaveBeenCalledTimes(3); + const userInsert = insertValues.mock.calls[0][0]; + expect(userInsert.email).toBe("newbie@example.com"); + expect(userInsert.emailVerified).toBe(true); + expect(userInsert.displayName).toBe("Newbie"); + expect(userInsert.passwordHash).toBeNull(); + expect(deleteChallengeMock).toHaveBeenCalledWith("session-id"); + }); + + test("happy path (phone): stores synthetic email, emailVerified=false", async () => { + const res = await POST(makeRequest(VALID_PHONE_BODY) as never); + expect(res.status).toBe(201); + const userInsert = insertValues.mock.calls[0][0]; + expect(userInsert.email).toBe("14155550100@phone.rxlab.local"); + expect(userInsert.emailVerified).toBe(false); + }); + + test("invalid_grant: expired/missing session", async () => { + getChallengeMock.mockResolvedValue(null); + const res = await POST(makeRequest(VALID_EMAIL_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("invalid_grant: challenge type mismatch", async () => { + getChallengeMock.mockResolvedValue({ + ...VALID_CHALLENGE, + type: "native-registration", + }); + const res = await POST(makeRequest(VALID_EMAIL_BODY) as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + }); + + test("user_exists: identifier already claimed", async () => { + findExistingUser.mockResolvedValue({ + id: "x", + email: "newbie@example.com", + }); + const res = await POST(makeRequest(VALID_EMAIL_BODY) as never); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe("user_exists"); + expect(insertValues).not.toHaveBeenCalled(); + expect(deleteChallengeMock).toHaveBeenCalledWith("session-id"); + }); + + test("invalid_request: malformed email identifier", async () => { + const res = await POST( + makeRequest({ + ...VALID_EMAIL_BODY, + contact_identifier: "not-an-email", + }) as never, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_request"); + expect(body.error_description).toContain("email"); + }); + + test("invalid_request: malformed phone identifier (too few digits)", async () => { + const res = await POST( + makeRequest({ + ...VALID_PHONE_BODY, + contact_identifier: "12", + }) as never, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_request"); + expect(body.error_description).toContain("phone"); + }); + + test("invalid_grant: WebAuthn registration verification fails", async () => { + verifyRegistrationResponseMock.mockResolvedValue({ verified: false }); + const res = await POST(makeRequest(VALID_EMAIL_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_EMAIL_BODY, scope: "openid admin:all" }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_scope"); + }); + + test("invalid_client: unknown client_id at verify time", async () => { + findClient.mockResolvedValue(undefined); + const res = await POST(makeRequest(VALID_EMAIL_BODY) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_client"); + }); +}); diff --git a/app/api/oauth/passkey/account-creation/verify/route.ts b/app/api/oauth/passkey/account-creation/verify/route.ts new file mode 100644 index 0000000..4029a61 --- /dev/null +++ b/app/api/oauth/passkey/account-creation/verify/route.ts @@ -0,0 +1,208 @@ +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, expectedOrigins, base64UrlEncode } from "@/lib/webauthn/config"; +import { generateAvatarSeed } from "@/lib/identicon/generate"; +import { passkeyAccountCreationVerifyRequestSchema } from "@/lib/validations/oauth"; +import { + validateClientRedirect, + resolveRequestedScopes, +} from "@/lib/oauth/native-client"; +import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; +import { checkSignUpAllowed } from "@/lib/settings/sign-up"; +import { normalizeContactIdentifier } from "@/lib/oauth/contact-identifier"; + +// POST /api/oauth/passkey/account-creation/verify +// +// Completes the iOS 26 / macOS 26 ASAuthorizationAccountCreationProvider +// flow. The system sheet has now returned the contact_identifier (email +// or phone) along with the attestation. We verify the attestation against +// the challenge, normalize/validate the identifier, create the user row +// (marked verified — Apple's flow is system-confirmed), persist the +// passkey, and issue OAuth tokens. +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 = passkeyAccountCreationVerifyRequestSchema.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 challengeData = await getWebAuthnChallenge(data.session_id); + if ( + !challengeData || + challengeData.type !== "passkey-account-creation" || + challengeData.clientId !== data.client_id || + !challengeData.userId + ) { + return NextResponse.json( + { + error: "invalid_grant", + error_description: "No account-creation challenge found", + }, + { status: 400 }, + ); + } + + // Re-validate the stored redirect_uri against the client's current + // allow-list (defense-in-depth, matches register/verify). + const clientCheck = await validateClientRedirect({ + clientId: data.client_id, + redirectUri: challengeData.redirectUri, + }); + 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 normalized = normalizeContactIdentifier( + data.contact_identifier, + data.contact_identifier_type, + ); + if (!normalized.ok) { + return NextResponse.json( + { error: "invalid_request", error_description: normalized.reason }, + { status: 400 }, + ); + } + + // Apply the sign-up gate now that we know the identifier (covers the + // whitelist case the /options step couldn't check). + const signUpCheck = await checkSignUpAllowed(normalized.storageEmail); + 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 }, + ); + } + + // Reject if another user already claimed this identifier. + const existing = await db.query.users.findFirst({ + where: eq(users.email, normalized.storageEmail), + }); + if (existing) { + await deleteWebAuthnChallenge(data.session_id); + return NextResponse.json( + { + error: "user_exists", + error_description: "An account with this identifier already exists", + }, + { status: 409 }, + ); + } + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: data.credential as Parameters< + typeof verifyRegistrationResponse + >[0]["response"], + expectedChallenge: challengeData.challenge, + expectedOrigin: expectedOrigins, + expectedRPID: rpID, + }); + } catch (error) { + console.error("Passkey account-creation 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 displayName = + data.name || normalized.storageEmail.split("@")[0] || "User"; + const avatarSeed = generateAvatarSeed(); + const now = new Date(); + + await db.transaction(async (tx) => { + await tx.insert(users).values({ + id: userId, + email: normalized.storageEmail, + passwordHash: null, + displayName, + avatarSeed, + emailVerified: normalized.emailVerified, + 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: normalized.storageEmail, + emailVerified: normalized.emailVerified, + displayName, + username: null, + avatarSeed, + avatarUrl: null, + }, + client, + scopes: requestedScopes, + }); + + return NextResponse.json(tokenResponse, { status: 201 }); +} diff --git a/app/api/oauth/passkey/upgrade/options/route.test.ts b/app/api/oauth/passkey/upgrade/options/route.test.ts new file mode 100644 index 0000000..9004171 --- /dev/null +++ b/app/api/oauth/passkey/upgrade/options/route.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; + +const USER_ID = "user-1"; +const USER_EMAIL = "user@example.com"; + +const VALID_USER = { + id: USER_ID, + email: USER_EMAIL, + displayName: "User One", + passwordHash: "hash", + username: null, + avatarSeed: "seed", + avatarUrl: null, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const findUser = mock(); +const findPasskeys = mock(); +const storeChallengeMock = mock(); +const verifyAccessTokenMock = mock(); +const generateRegistrationOptionsMock = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + users: { findFirst: findUser }, + passkeys: { findMany: findPasskeys }, + }, + }, +})); + +mock.module("@/lib/redis", () => ({ + storeWebAuthnChallenge: storeChallengeMock, + getWebAuthnChallenge: mock(), + deleteWebAuthnChallenge: mock(), + getOAuthCode: mock(), + deleteOAuthCode: mock(), + storeOAuthCode: mock(), +})); + +mock.module("@simplewebauthn/server", () => ({ + generateRegistrationOptions: generateRegistrationOptionsMock, + generateAuthenticationOptions: mock(), + verifyAuthenticationResponse: mock(), + verifyRegistrationResponse: mock(), +})); + +mock.module("@/lib/oauth/jwt", () => ({ + verifyAccessToken: verifyAccessTokenMock, + signAccessToken: mock(), + signIdToken: mock(), + generateRefreshToken: mock(), +})); + +const { POST } = await import("./route"); + +function makeRequest( + body: Record | undefined, + authHeader: string | null = "Bearer good-token", +): Request { + return new Request( + "https://auth.rxlab.app/api/oauth/passkey/upgrade/options", + { + method: "POST", + headers: { + "content-type": "application/json", + ...(authHeader ? { Authorization: authHeader } : {}), + }, + body: body === undefined ? "" : JSON.stringify(body), + }, + ); +} + +describe("POST /api/oauth/passkey/upgrade/options", () => { + beforeEach(() => { + findUser.mockReset(); + findPasskeys.mockReset(); + storeChallengeMock.mockReset(); + verifyAccessTokenMock.mockReset(); + generateRegistrationOptionsMock.mockReset(); + + verifyAccessTokenMock.mockResolvedValue({ + sub: USER_ID, + client_id: "macos-test-app", + scope: "openid read:email", + }); + findUser.mockResolvedValue(VALID_USER); + findPasskeys.mockResolvedValue([]); + storeChallengeMock.mockResolvedValue(undefined); + generateRegistrationOptionsMock.mockResolvedValue({ + challenge: "challenge-stub", + rp: { id: "rxlab.app", name: "RxLab Auth" }, + user: { id: "uid", name: USER_EMAIL, displayName: "User One" }, + pubKeyCredParams: [], + attestation: "none", + excludeCredentials: [], + }); + }); + + test("happy path: returns options + sessionId and stores passkey-upgrade challenge", async () => { + const res = await POST(makeRequest({}) as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.sessionId).toBe("string"); + expect(body.challenge).toBe("challenge-stub"); + expect(storeChallengeMock).toHaveBeenCalledTimes(1); + const stored = storeChallengeMock.mock.calls[0][1]; + expect(stored.type).toBe("passkey-upgrade"); + expect(stored.userId).toBe(USER_ID); + expect(stored.clientId).toBe("macos-test-app"); + }); + + test("accepts empty body", async () => { + const res = await POST(makeRequest(undefined) as never); + expect(res.status).toBe(200); + }); + + test("includes excludeCredentials from user's existing passkeys", async () => { + findPasskeys.mockResolvedValue([ + { id: "cred-1", transports: JSON.stringify(["internal"]) }, + { id: "cred-2", transports: null }, + ]); + await POST(makeRequest({}) as never); + const optsArg = generateRegistrationOptionsMock.mock.calls[0][0]; + expect(optsArg.excludeCredentials).toEqual([ + { id: "cred-1", transports: ["internal"] }, + { id: "cred-2", transports: [] }, + ]); + }); + + test("invalid_token: missing Authorization header", async () => { + const res = await POST(makeRequest({}, null) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_token"); + }); + + test("invalid_token: malformed Authorization header", async () => { + const res = await POST(makeRequest({}, "Basic abc") as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_token"); + }); + + test("invalid_token: access token verification fails", async () => { + verifyAccessTokenMock.mockRejectedValue(new Error("expired")); + const res = await POST(makeRequest({}) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_token"); + }); + + test("invalid_token: user row missing for token sub", async () => { + findUser.mockResolvedValue(undefined); + const res = await POST(makeRequest({}) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_token"); + }); +}); diff --git a/app/api/oauth/passkey/upgrade/options/route.ts b/app/api/oauth/passkey/upgrade/options/route.ts new file mode 100644 index 0000000..3c7785a --- /dev/null +++ b/app/api/oauth/passkey/upgrade/options/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateRegistrationOptions } 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 { + getRegistrationOptions, + parseTransports, +} from "@/lib/webauthn/config"; +import { passkeyUpgradeOptionsRequestSchema } from "@/lib/validations/oauth"; +import { requireBearerToken } from "@/lib/oauth/bearer"; + +// POST /api/oauth/passkey/upgrade/options +// +// Apple Automatic Passkey Upgrade — silently registers a platform passkey +// for the already-authenticated user. Caller MUST present a valid OAuth +// access token in `Authorization: Bearer `; the user is derived from +// the token's `sub` claim, not from any request body field. +// +// Returns the same SimpleWebAuthn registration options shape the existing +// /register/options route returns, plus a `sessionId` the client passes back +// to /upgrade/verify. +export async function POST(request: NextRequest) { + const auth = await requireBearerToken(request); + if (!auth.ok) return auth.response; + + // Body is optional — only `name` is read. If parsing fails (e.g. empty + // body), fall back to an empty object so the schema's optional fields + // still validate. + let body: unknown = {}; + try { + body = await request.json(); + } catch { + body = {}; + } + + const parsed = passkeyUpgradeOptionsRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "invalid_request", + error_description: parsed.error.issues[0]?.message, + }, + { status: 400 }, + ); + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, auth.payload.sub), + }); + if (!user) { + return NextResponse.json( + { error: "invalid_token", error_description: "User not found" }, + { status: 401 }, + ); + } + + const existingPasskeys = await db.query.passkeys.findMany({ + where: eq(passkeys.userId, user.id), + }); + const excludeCredentials = existingPasskeys.map((pk) => ({ + id: pk.id, + transports: parseTransports(pk.transports), + })); + + const displayName = user.displayName || user.email.split("@")[0]; + const sessionId = crypto.randomUUID(); + + const options = getRegistrationOptions( + user.id, + user.email, + displayName, + excludeCredentials, + ); + const registrationOptions = await generateRegistrationOptions(options); + + const challengeData: WebAuthnChallengeData = { + challenge: registrationOptions.challenge, + userId: user.id, + type: "passkey-upgrade", + clientId: auth.payload.client_id, + createdAt: Date.now(), + }; + await storeWebAuthnChallenge(sessionId, challengeData); + + return NextResponse.json({ + ...registrationOptions, + sessionId, + }); +} diff --git a/app/api/oauth/passkey/upgrade/verify/route.test.ts b/app/api/oauth/passkey/upgrade/verify/route.test.ts new file mode 100644 index 0000000..b6e934d --- /dev/null +++ b/app/api/oauth/passkey/upgrade/verify/route.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; + +const USER_ID = "user-1"; +const USER_EMAIL = "user@example.com"; + +const VALID_USER = { + id: USER_ID, + email: USER_EMAIL, + displayName: "User One", + passwordHash: "hash", + username: null, + avatarSeed: "seed", + avatarUrl: null, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const VALID_CHALLENGE = { + challenge: "challenge-stub", + userId: USER_ID, + type: "passkey-upgrade" as const, + clientId: "macos-test-app", + createdAt: Date.now(), +}; + +const findUser = mock(); +const findExistingPasskey = mock(); +const insertValues = mock(); +const getChallengeMock = mock(); +const deleteChallengeMock = mock(); +const verifyAccessTokenMock = mock(); +const verifyRegistrationResponseMock = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + users: { findFirst: findUser }, + passkeys: { findFirst: findExistingPasskey }, + }, + insert: () => ({ values: insertValues }), + }, +})); + +mock.module("@/lib/redis", () => ({ + getWebAuthnChallenge: getChallengeMock, + deleteWebAuthnChallenge: deleteChallengeMock, + storeWebAuthnChallenge: mock(), + getOAuthCode: mock(), + deleteOAuthCode: mock(), + storeOAuthCode: mock(), +})); + +mock.module("@simplewebauthn/server", () => ({ + verifyRegistrationResponse: verifyRegistrationResponseMock, + verifyAuthenticationResponse: mock(), + generateRegistrationOptions: mock(), + generateAuthenticationOptions: mock(), +})); + +mock.module("@/lib/oauth/jwt", () => ({ + verifyAccessToken: verifyAccessTokenMock, + signAccessToken: mock(), + signIdToken: mock(), + generateRefreshToken: mock(), +})); + +const { POST } = await import("./route"); + +const VALID_BODY = { + session_id: "session-id", + credential: { + id: "new-cred-id", + rawId: "new-cred-id", + type: "public-key", + response: { + clientDataJSON: "abc", + attestationObject: "def", + transports: ["internal"], + }, + }, +}; + +function makeRequest( + body: Record, + authHeader: string | null = "Bearer good-token", +): Request { + return new Request( + "https://auth.rxlab.app/api/oauth/passkey/upgrade/verify", + { + method: "POST", + headers: { + "content-type": "application/json", + ...(authHeader ? { Authorization: authHeader } : {}), + }, + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/oauth/passkey/upgrade/verify", () => { + beforeEach(() => { + findUser.mockReset(); + findExistingPasskey.mockReset(); + insertValues.mockReset(); + getChallengeMock.mockReset(); + deleteChallengeMock.mockReset(); + verifyAccessTokenMock.mockReset(); + verifyRegistrationResponseMock.mockReset(); + + verifyAccessTokenMock.mockResolvedValue({ + sub: USER_ID, + client_id: "macos-test-app", + scope: "openid", + }); + findUser.mockResolvedValue(VALID_USER); + findExistingPasskey.mockResolvedValue(undefined); + getChallengeMock.mockResolvedValue(VALID_CHALLENGE); + deleteChallengeMock.mockResolvedValue(undefined); + insertValues.mockResolvedValue(undefined); + verifyRegistrationResponseMock.mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: "new-cred-id", + publicKey: new Uint8Array([1, 2, 3]), + counter: 0, + }, + credentialDeviceType: "platform", + credentialBackedUp: true, + }, + }); + }); + + test("happy path: persists passkey against authenticated user and returns 201", async () => { + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.credential_id).toBe("new-cred-id"); + expect(insertValues).toHaveBeenCalledTimes(1); + const inserted = insertValues.mock.calls[0][0]; + expect(inserted.userId).toBe(USER_ID); + expect(inserted.id).toBe("new-cred-id"); + expect(deleteChallengeMock).toHaveBeenCalledWith("session-id"); + }); + + test("invalid_token: missing Authorization header", async () => { + const res = await POST(makeRequest(VALID_BODY, null) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_token"); + }); + + test("invalid_token: access token verification fails", async () => { + verifyAccessTokenMock.mockRejectedValue(new Error("expired")); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_token"); + }); + + test("invalid_grant: no 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", async () => { + getChallengeMock.mockResolvedValue({ + ...VALID_CHALLENGE, + type: "native-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: challenge bound to different user than token sub", async () => { + getChallengeMock.mockResolvedValue({ + ...VALID_CHALLENGE, + userId: "different-user", + }); + 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 () => { + 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("credential_exists: same credential ID already in passkeys table", async () => { + findExistingPasskey.mockResolvedValue({ id: "new-cred-id" }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe("credential_exists"); + expect(insertValues).not.toHaveBeenCalled(); + expect(deleteChallengeMock).toHaveBeenCalledWith("session-id"); + }); + + test("invalid_request: body is not JSON", async () => { + const req = new Request( + "https://auth.rxlab.app/api/oauth/passkey/upgrade/verify", + { + method: "POST", + headers: { + "content-type": "application/json", + Authorization: "Bearer good-token", + }, + body: "not-json{", + }, + ); + const res = await POST(req as never); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_request"); + }); + + test("invalid_request: missing session_id", async () => { + const res = await POST( + makeRequest({ credential: VALID_BODY.credential }) as never, + ); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_request"); + }); +}); diff --git a/app/api/oauth/passkey/upgrade/verify/route.ts b/app/api/oauth/passkey/upgrade/verify/route.ts new file mode 100644 index 0000000..83750ba --- /dev/null +++ b/app/api/oauth/passkey/upgrade/verify/route.ts @@ -0,0 +1,141 @@ +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, expectedOrigins, base64UrlEncode } from "@/lib/webauthn/config"; +import { passkeyUpgradeVerifyRequestSchema } from "@/lib/validations/oauth"; +import { requireBearerToken } from "@/lib/oauth/bearer"; + +// POST /api/oauth/passkey/upgrade/verify +// +// Completes the Automatic Passkey Upgrade ceremony. Verifies the attestation +// against the challenge stored under `session_id`, asserts the challenge was +// issued for the same user as the Bearer token, and persists the credential +// against the user. Does NOT create users or issue OAuth tokens — the caller +// is already authenticated. +export async function POST(request: NextRequest) { + const auth = await requireBearerToken(request); + if (!auth.ok) return auth.response; + + 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 = passkeyUpgradeVerifyRequestSchema.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 challengeData = await getWebAuthnChallenge(data.session_id); + if ( + !challengeData || + challengeData.type !== "passkey-upgrade" || + challengeData.userId !== auth.payload.sub + ) { + return NextResponse.json( + { + error: "invalid_grant", + error_description: "No upgrade challenge found for this session", + }, + { status: 400 }, + ); + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, auth.payload.sub), + }); + if (!user) { + await deleteWebAuthnChallenge(data.session_id); + return NextResponse.json( + { error: "invalid_token", error_description: "User not found" }, + { status: 401 }, + ); + } + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: data.credential as Parameters< + typeof verifyRegistrationResponse + >[0]["response"], + expectedChallenge: challengeData.challenge, + expectedOrigin: expectedOrigins, + expectedRPID: rpID, + }); + } catch (error) { + console.error("Passkey upgrade 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; + + // Reject if this credential ID is already registered (defense-in-depth; + // excludeCredentials in /options should normally prevent this). + const existing = await db.query.passkeys.findFirst({ + where: eq(passkeys.id, cred.id), + }); + if (existing) { + await deleteWebAuthnChallenge(data.session_id); + return NextResponse.json( + { + error: "credential_exists", + error_description: "This passkey is already registered", + }, + { status: 409 }, + ); + } + + const transportsArr = (data.credential.response as { transports?: string[] }) + .transports; + + await db.insert(passkeys).values({ + id: cred.id, + userId: user.id, + name: data.name || "Passkey", + publicKey: base64UrlEncode(cred.publicKey), + counter: cred.counter, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + transports: + transportsArr && Array.isArray(transportsArr) + ? JSON.stringify(transportsArr) + : null, + createdAt: new Date(), + }); + + await deleteWebAuthnChallenge(data.session_id); + + return NextResponse.json( + { ok: true, credential_id: cred.id }, + { status: 201 }, + ); +} diff --git a/app/api/oauth/token/password-grant.test.ts b/app/api/oauth/token/password-grant.test.ts index e20391e..53ad45c 100644 --- a/app/api/oauth/token/password-grant.test.ts +++ b/app/api/oauth/token/password-grant.test.ts @@ -62,6 +62,7 @@ mock.module("@/lib/oauth/jwt", () => ({ signAccessToken: signAccessTokenMock, signIdToken: signIdTokenMock, generateRefreshToken: generateRefreshTokenMock, + verifyAccessToken: mock(), })); mock.module("@/lib/redis", () => ({ diff --git a/lib/oauth/bearer.ts b/lib/oauth/bearer.ts new file mode 100644 index 0000000..139f78b --- /dev/null +++ b/lib/oauth/bearer.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { JWTPayload } from "jose"; +import { verifyAccessToken, type AccessTokenPayload } from "@/lib/oauth/jwt"; + +export type BearerCheck = + | { ok: true; payload: JWTPayload & AccessTokenPayload } + | { ok: false; response: NextResponse }; + +// Parse `Authorization: Bearer ` and verify the access token. +// On failure returns a 401 with WWW-Authenticate set per RFC 6750. +export async function requireBearerToken( + request: NextRequest, +): Promise { + const header = request.headers.get("Authorization"); + if (!header || !header.startsWith("Bearer ")) { + return { + ok: false, + response: NextResponse.json( + { + error: "invalid_token", + error_description: "Missing or invalid Authorization header", + }, + { + status: 401, + headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' }, + }, + ), + }; + } + const token = header.substring(7); + try { + const payload = await verifyAccessToken(token); + return { ok: true, payload }; + } catch { + return { + ok: false, + response: NextResponse.json( + { + error: "invalid_token", + error_description: "Invalid or expired token", + }, + { + status: 401, + headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' }, + }, + ), + }; + } +} diff --git a/lib/oauth/contact-identifier.ts b/lib/oauth/contact-identifier.ts new file mode 100644 index 0000000..3d05856 --- /dev/null +++ b/lib/oauth/contact-identifier.ts @@ -0,0 +1,66 @@ +// Normalize the contact identifier returned by the iOS/macOS +// ASAuthorizationAccountCreationProvider sheet into the canonical form we +// store on the user row. +// +// The users table has only an `email` column (no phone column today), so +// phone-typed identifiers are stored as a synthetic email of the form +// `@phone.rxlab.local` with `emailVerified=false`. This keeps +// the existing unique-email constraint and lookup paths working without a +// schema migration. A follow-up should add a dedicated `phone` column. + +const PHONE_SYNTHETIC_DOMAIN = "phone.rxlab.local"; + +// RFC-ish email check: same shape Zod's .email() validates against. +const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; + +export type NormalizedContact = + | { ok: true; type: "email"; storageEmail: string; emailVerified: true } + | { + ok: true; + type: "phone_number"; + storageEmail: string; + emailVerified: false; + e164: string; + } + | { ok: false; reason: string }; + +export function normalizeContactIdentifier( + raw: string, + type: "email" | "phone_number", +): NormalizedContact { + if (type === "email") { + const email = raw.trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + return { ok: false, reason: "contact_identifier must be a valid email" }; + } + return { ok: true, type: "email", storageEmail: email, emailVerified: true }; + } + + // phone_number: keep digits only; require leading + or assume one. + const trimmed = raw.trim(); + const hasPlus = trimmed.startsWith("+"); + const digits = trimmed.replace(/\D/g, ""); + if (digits.length < 6 || digits.length > 15) { + return { + ok: false, + reason: "contact_identifier must be a valid phone number", + }; + } + const e164 = `+${digits}`; + // Reject obvious malformed inputs (e.g. multiple plus signs in the middle). + if (!hasPlus && trimmed.includes("+")) { + return { + ok: false, + reason: "contact_identifier must be a valid phone number", + }; + } + return { + ok: true, + type: "phone_number", + storageEmail: `${digits}@${PHONE_SYNTHETIC_DOMAIN}`, + emailVerified: false, + e164, + }; +} + +export { PHONE_SYNTHETIC_DOMAIN }; diff --git a/lib/redis/index.ts b/lib/redis/index.ts index 068e7c0..534af70 100644 --- a/lib/redis/index.ts +++ b/lib/redis/index.ts @@ -48,7 +48,9 @@ export interface WebAuthnChallengeData { | "registration" | "authentication" | "native-registration" - | "native-authentication"; + | "native-authentication" + | "passkey-upgrade" + | "passkey-account-creation"; createdAt: number; // Native (OAuth) passkey flows pin the challenge to a specific client. clientId?: string; diff --git a/lib/ui-schema/build.test.ts b/lib/ui-schema/build.test.ts new file mode 100644 index 0000000..e6c7599 --- /dev/null +++ b/lib/ui-schema/build.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import { + buildSigninSchema, + buildSignupSchema, + type OAuthClientLite, +} from "./build"; + +const FIRST_PARTY: OAuthClientLite = { + id: "macos-test-app", + name: "macOS Test App", + signInPermission: "all", +}; + +const RESTRICTED: OAuthClientLite = { + id: "locked", + name: "Locked Client", + signInPermission: "none", +}; + +describe("buildSigninSchema", () => { + test("first-party client exposes both passkey and password", () => { + const schema = buildSigninSchema({ client: FIRST_PARTY }); + + expect(schema.flow).toBe("signin"); + expect(schema.title).toBe("Sign in to macOS Test App"); + expect(schema.submitLabel).toBe("Sign in"); + + const methodIds = schema.supportedMethods.map((m) => m.id); + expect(methodIds).toEqual(["password", "passkey"]); + expect(schema.supportedMethods[0]?.primary).toBe(true); + }); + + test("client with signInPermission!=all hides password + passkey", () => { + const schema = buildSigninSchema({ client: RESTRICTED }); + expect(schema.supportedMethods).toEqual([]); + }); + + test("no client_id falls back to default title", () => { + const schema = buildSigninSchema({ client: null }); + expect(schema.title).toBe("Sign in to RxLab"); + expect(schema.supportedMethods).toEqual([]); + }); + + test("fields include email + password with derived validation", () => { + const schema = buildSigninSchema({ client: FIRST_PARTY }); + const keys = schema.fields.map((f) => f.key); + expect(keys).toEqual(["email", "password"]); + + const email = schema.fields[0]!; + expect(email.type).toBe("email"); + expect(email.isPassword).toBe(false); + expect(email.required).toBe(true); + expect(email.autocomplete).toBe("username"); + // Email validation is sourced from emailSchema, so pattern + minLength + // come from zod-to-json-schema. We just assert the shape is present. + expect(typeof email.validation.pattern).toBe("string"); + expect(email.validation.minLength).toBeGreaterThan(0); + + const password = schema.fields[1]!; + expect(password.type).toBe("password"); + expect(password.isPassword).toBe(true); + expect(password.autocomplete).toBe("current-password"); + expect(password.validation.minLength).toBe(8); + expect(password.validation.maxLength).toBe(128); + }); +}); + +describe("buildSignupSchema", () => { + test("first-party client with sign-up allowed (flag off) exposes legacy passkey + password", () => { + const schema = buildSignupSchema({ + client: FIRST_PARTY, + signUpAllowed: true, + }); + + expect(schema.flow).toBe("signup"); + expect(schema.title).toBe("Create your macOS Test App account"); + expect(schema.submitLabel).toBe("Create account"); + + const methodIds = schema.supportedMethods.map((m) => m.id); + expect(methodIds).toEqual(["password", "passkey"]); + }); + + test("accountCreationEnabled replaces legacy passkey with passkey_account_creation", () => { + const schema = buildSignupSchema({ + client: FIRST_PARTY, + signUpAllowed: true, + accountCreationEnabled: true, + }); + + const methodIds = schema.supportedMethods.map((m) => m.id); + expect(methodIds).toEqual(["password", "passkey_account_creation"]); + expect(methodIds).not.toContain("passkey"); + + const entry = schema.supportedMethods.find( + (m) => m.id === "passkey_account_creation", + ); + expect(entry).toEqual({ + id: "passkey_account_creation", + label: "Sign up with Passkey", + primary: false, + }); + // Password is still primary; account-creation must not steal that. + expect(schema.supportedMethods[0]?.id).toBe("password"); + expect(schema.supportedMethods[0]?.primary).toBe(true); + }); + + test("accountCreationEnabled default (off) omits passkey_account_creation", () => { + const schema = buildSignupSchema({ + client: FIRST_PARTY, + signUpAllowed: true, + }); + const methodIds = schema.supportedMethods.map((m) => m.id); + expect(methodIds).not.toContain("passkey_account_creation"); + }); + + test("accountCreationEnabled is gated by signUpAllowed", () => { + const schema = buildSignupSchema({ + client: FIRST_PARTY, + signUpAllowed: false, + accountCreationEnabled: true, + }); + expect(schema.supportedMethods).toEqual([]); + }); + + test("accountCreationEnabled is gated by first-party client", () => { + const schema = buildSignupSchema({ + client: RESTRICTED, + signUpAllowed: true, + accountCreationEnabled: true, + }); + expect(schema.supportedMethods).toEqual([]); + }); + + test("sign-up disabled empties supportedMethods", () => { + const schema = buildSignupSchema({ + client: FIRST_PARTY, + signUpAllowed: false, + }); + expect(schema.supportedMethods).toEqual([]); + }); + + test("non-first-party client empties supportedMethods", () => { + const schema = buildSignupSchema({ + client: RESTRICTED, + signUpAllowed: true, + }); + expect(schema.supportedMethods).toEqual([]); + }); + + test("password field uses new-password autocomplete", () => { + const schema = buildSignupSchema({ + client: FIRST_PARTY, + signUpAllowed: true, + }); + const password = schema.fields.find((f) => f.key === "password"); + expect(password?.autocomplete).toBe("new-password"); + }); + + test("name field is optional", () => { + const schema = buildSignupSchema({ + client: FIRST_PARTY, + signUpAllowed: true, + }); + const name = schema.fields.find((f) => f.key === "name"); + expect(name?.required).toBe(false); + // displayNameSchema → max 64 chars + expect(name?.validation.maxLength).toBe(64); + }); +}); diff --git a/lib/ui-schema/build.ts b/lib/ui-schema/build.ts new file mode 100644 index 0000000..8761c44 --- /dev/null +++ b/lib/ui-schema/build.ts @@ -0,0 +1,223 @@ +import { z } from "zod"; +import { + emailSchema, + passwordSchema, + displayNameSchema, +} from "@/lib/validations/auth"; + +export type Flow = "signin" | "signup"; + +export type FieldType = "text" | "email" | "password" | "name"; + +export interface FieldValidation { + minLength?: number; + maxLength?: number; + pattern?: string; + patternMessage?: string; +} + +export interface UiField { + key: string; + label: string; + placeholder: string; + type: FieldType; + isPassword: boolean; + required: boolean; + autocomplete?: string; + validation: FieldValidation; +} + +export type AuthMethodId = "password" | "passkey" | "passkey_account_creation"; + +export interface UiMethod { + id: AuthMethodId; + label: string; + primary: boolean; +} + +export interface UiLink { + id: string; + label: string; + href: string; +} + +export interface UiSchema { + flow: Flow; + title: string; + submitLabel: string; + fields: UiField[]; + supportedMethods: UiMethod[]; + links: UiLink[]; +} + +export interface OAuthClientLite { + id: string; + name: string; + signInPermission: "all" | "none" | "whitelist"; +} + +// Derive { minLength, maxLength, pattern } from a Zod string schema using the +// JSON-schema export built into Zod 4. Keeps the UI rules in lockstep with the +// server's actual validators (no hand-duplicated regexes). +function describeStringField( + schema: z.ZodTypeAny, + patternMessage?: string, +): FieldValidation { + const json = z.toJSONSchema(schema) as { + minLength?: number; + maxLength?: number; + pattern?: string; + }; + const out: FieldValidation = {}; + if (typeof json.minLength === "number") out.minLength = json.minLength; + if (typeof json.maxLength === "number") out.maxLength = json.maxLength; + if (typeof json.pattern === "string") { + out.pattern = json.pattern; + if (patternMessage) out.patternMessage = patternMessage; + } + return out; +} + +function emailField(): UiField { + return { + key: "email", + label: "Email", + placeholder: "you@example.com", + type: "email", + isPassword: false, + required: true, + autocomplete: "username", + validation: describeStringField(emailSchema, "Enter a valid email address"), + }; +} + +function passwordField(opts: { autocomplete: "current-password" | "new-password" }): UiField { + return { + key: "password", + label: "Password", + placeholder: "Your password", + type: "password", + isPassword: true, + required: true, + autocomplete: opts.autocomplete, + validation: describeStringField(passwordSchema), + }; +} + +function nameField(): UiField { + return { + key: "name", + label: "Name", + placeholder: "Your name", + type: "name", + isPassword: false, + required: false, + autocomplete: "name", + validation: describeStringField(displayNameSchema), + }; +} + +export interface BuildSigninInput { + client: OAuthClientLite | null; +} + +export function buildSigninSchema(input: BuildSigninInput): UiSchema { + const { client } = input; + const passwordAllowed = client?.signInPermission === "all"; + + const methods: UiMethod[] = []; + if (passwordAllowed) { + methods.push({ + id: "password", + label: "Sign in with password", + primary: true, + }); + methods.push({ + id: "passkey", + label: "Sign in with passkey", + primary: false, + }); + } + + const title = client?.name + ? `Sign in to ${client.name}` + : "Sign in to RxLab"; + + return { + flow: "signin", + title, + submitLabel: "Sign in", + fields: [emailField(), passwordField({ autocomplete: "current-password" })], + supportedMethods: methods, + links: [ + { id: "forgot-password", label: "Forgot password?", href: "/reset" }, + { + id: "switch-to-signup", + label: "Create an account", + href: "/signup", + }, + ], + }; +} + +export interface BuildSignupInput { + client: OAuthClientLite | null; + signUpAllowed: boolean; + // Feature flag for the iOS 26 / macOS 26 + // ASAuthorizationAccountCreationProvider system-sheet flow. When true and + // sign-up is otherwise allowed, the schema advertises the + // `passkey_account_creation` method so native clients can render the + // "Sign up with Passkey" button that triggers the OS sheet (which collects + // email/name itself — no extra fields needed). + accountCreationEnabled?: boolean; +} + +export function buildSignupSchema(input: BuildSignupInput): UiSchema { + const { client, signUpAllowed, accountCreationEnabled = false } = input; + const firstParty = client?.signInPermission === "all"; + + const methods: UiMethod[] = []; + if (firstParty && signUpAllowed) { + methods.push({ + id: "password", + label: "Sign up with password", + primary: true, + }); + // The Apple system-sheet flow (passkey_account_creation) supersedes the + // legacy `passkey` signup button — the latter required us to collect an + // email field up front, while the OS sheet collects it itself. When the + // new flow is on, only emit the system-sheet method. + if (accountCreationEnabled) { + methods.push({ + id: "passkey_account_creation", + label: "Sign up with Passkey", + primary: false, + }); + } else { + methods.push({ + id: "passkey", + label: "Sign up with passkey", + primary: false, + }); + } + } + + const title = client?.name + ? `Create your ${client.name} account` + : "Create your RxLab account"; + + return { + flow: "signup", + title, + submitLabel: "Create account", + fields: [ + emailField(), + passwordField({ autocomplete: "new-password" }), + nameField(), + ], + supportedMethods: methods, + links: [ + { id: "switch-to-signin", label: "Already have an account?", href: "/login" }, + ], + }; +} diff --git a/lib/validations/oauth.ts b/lib/validations/oauth.ts index 883b120..eb5e86e 100644 --- a/lib/validations/oauth.ts +++ b/lib/validations/oauth.ts @@ -132,6 +132,57 @@ export const passkeyRegisterVerifyRequestSchema = z.object({ scope: z.string().optional(), }); +// POST /api/oauth/passkey/upgrade/options +// Bearer-token-authenticated. Body is optional; `name` lets the client label +// the new credential ("MacBook Pro", etc.). +export const passkeyUpgradeOptionsRequestSchema = z.object({ + name: z.string().min(1).max(64).optional(), +}); + +// POST /api/oauth/passkey/upgrade/verify +// Bearer-token-authenticated. The user is derived from the access token, so +// no client_id / scope are needed — this route does not issue OAuth tokens. +export const passkeyUpgradeVerifyRequestSchema = z.object({ + session_id: z.string().min(1, "session_id is required"), + credential: passkeyAttestationSchema, + name: z.string().min(1).max(64).optional(), +}); + +export type PasskeyUpgradeOptionsRequest = z.infer< + typeof passkeyUpgradeOptionsRequestSchema +>; +export type PasskeyUpgradeVerifyRequest = z.infer< + typeof passkeyUpgradeVerifyRequestSchema +>; + +// POST /api/oauth/passkey/account-creation/options +// Pre-auth. iOS 26 / macOS 26 ASAuthorizationAccountCreationProvider — the +// OS sheet collects the contact identifier from iCloud at verify time, so +// no username/name is supplied here. +export const passkeyAccountCreationOptionsRequestSchema = z.object({ + client_id: z.string().min(1, "client_id is required"), + redirect_uri: z.string().min(1, "redirect_uri is required"), +}); + +// POST /api/oauth/passkey/account-creation/verify +// `contact_identifier` is the email or phone returned by the OS sheet. +export const passkeyAccountCreationVerifyRequestSchema = z.object({ + client_id: z.string().min(1, "client_id is required"), + session_id: z.string().min(1, "session_id is required"), + credential: passkeyAttestationSchema, + contact_identifier: z.string().min(1, "contact_identifier is required"), + contact_identifier_type: z.enum(["email", "phone_number"]), + name: z.string().min(1).max(64).optional(), + scope: z.string().optional(), +}); + +export type PasskeyAccountCreationOptionsRequest = z.infer< + typeof passkeyAccountCreationOptionsRequestSchema +>; +export type PasskeyAccountCreationVerifyRequest = z.infer< + typeof passkeyAccountCreationVerifyRequestSchema +>; + export type PasskeyAuthOptionsRequest = z.infer< typeof passkeyAuthOptionsRequestSchema >;