diff --git a/app/api/admin/passkey/authenticate/verify/route.ts b/app/api/admin/passkey/authenticate/verify/route.ts index d3ab6c4..bbda11f 100644 --- a/app/api/admin/passkey/authenticate/verify/route.ts +++ b/app/api/admin/passkey/authenticate/verify/route.ts @@ -4,7 +4,7 @@ import { db } from "@/lib/db"; import { adminPasskeys } from "@/lib/db/schema"; import { createAdminSession } from "@/lib/auth/session"; import { getWebAuthnChallenge, deleteWebAuthnChallenge } from "@/lib/redis"; -import { rpID, origin, base64UrlDecode } from "@/lib/webauthn/config"; +import { rpID, expectedOrigins, base64UrlDecode } from "@/lib/webauthn/config"; import { eq } from "drizzle-orm"; export async function POST(request: NextRequest) { @@ -44,7 +44,7 @@ export async function POST(request: NextRequest) { const verification = await verifyAuthenticationResponse({ response: credential, expectedChallenge: challengeData.challenge, - expectedOrigin: origin, + expectedOrigin: expectedOrigins, expectedRPID: rpID, credential: { id: passkey.id, diff --git a/app/api/admin/passkey/register/verify/route.ts b/app/api/admin/passkey/register/verify/route.ts index 23679db..754722e 100644 --- a/app/api/admin/passkey/register/verify/route.ts +++ b/app/api/admin/passkey/register/verify/route.ts @@ -4,7 +4,7 @@ import { db } from "@/lib/db"; import { adminPasskeys } from "@/lib/db/schema"; import { getAdminSession } from "@/lib/auth/session"; import { getWebAuthnChallenge, deleteWebAuthnChallenge } from "@/lib/redis"; -import { rpID, origin, base64UrlEncode } from "@/lib/webauthn/config"; +import { rpID, expectedOrigins, base64UrlEncode } from "@/lib/webauthn/config"; export async function POST(request: NextRequest) { try { @@ -34,7 +34,7 @@ export async function POST(request: NextRequest) { const verification = await verifyRegistrationResponse({ response: credential, expectedChallenge: challengeData.challenge, - expectedOrigin: origin, + expectedOrigin: expectedOrigins, expectedRPID: rpID, }); diff --git a/app/api/auth/passkey/authenticate/verify/route.ts b/app/api/auth/passkey/authenticate/verify/route.ts index 43fecb7..ed596e2 100644 --- a/app/api/auth/passkey/authenticate/verify/route.ts +++ b/app/api/auth/passkey/authenticate/verify/route.ts @@ -4,7 +4,7 @@ import { db } from "@/lib/db"; import { users, passkeys } from "@/lib/db/schema"; import { createSession } from "@/lib/auth/session"; import { getWebAuthnChallenge, deleteWebAuthnChallenge } from "@/lib/redis"; -import { rpID, origin, base64UrlDecode } from "@/lib/webauthn/config"; +import { rpID, expectedOrigins, base64UrlDecode } from "@/lib/webauthn/config"; import { eq } from "drizzle-orm"; export async function POST(request: NextRequest) { @@ -56,7 +56,7 @@ export async function POST(request: NextRequest) { const verification = await verifyAuthenticationResponse({ response: credential, expectedChallenge: challengeData.challenge, - expectedOrigin: origin, + expectedOrigin: expectedOrigins, expectedRPID: rpID, credential: { id: passkey.id, // base64url string diff --git a/app/api/auth/passkey/register/verify/route.ts b/app/api/auth/passkey/register/verify/route.ts index d575023..7720aec 100644 --- a/app/api/auth/passkey/register/verify/route.ts +++ b/app/api/auth/passkey/register/verify/route.ts @@ -4,7 +4,7 @@ import { db } from "@/lib/db"; import { passkeys } from "@/lib/db/schema"; import { getSession } from "@/lib/auth/session"; import { getWebAuthnChallenge, deleteWebAuthnChallenge } from "@/lib/redis"; -import { rpID, origin, base64UrlEncode } from "@/lib/webauthn/config"; +import { rpID, expectedOrigins, base64UrlEncode } from "@/lib/webauthn/config"; export async function POST(request: NextRequest) { try { @@ -34,7 +34,7 @@ export async function POST(request: NextRequest) { const verification = await verifyRegistrationResponse({ response: credential, expectedChallenge: challengeData.challenge, - expectedOrigin: origin, + expectedOrigin: expectedOrigins, expectedRPID: rpID, }); diff --git a/app/api/oauth/passkey/authenticate/options/route.test.ts b/app/api/oauth/passkey/authenticate/options/route.test.ts new file mode 100644 index 0000000..61d33cd --- /dev/null +++ b/app/api/oauth/passkey/authenticate/options/route.test.ts @@ -0,0 +1,151 @@ +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 findUser = mock(); +const findManyPasskeys = mock(); +const storeChallengeMock = mock(); +const generateAuthenticationOptionsMock = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + oauthClients: { findFirst: findClient }, + users: { findFirst: findUser }, + passkeys: { findMany: findManyPasskeys }, + }, + }, +})); + +mock.module("@/lib/redis", () => ({ + storeWebAuthnChallenge: storeChallengeMock, + getWebAuthnChallenge: mock(), + deleteWebAuthnChallenge: mock(), + getOAuthCode: mock(), + deleteOAuthCode: mock(), + storeOAuthCode: mock(), +})); + +mock.module("@simplewebauthn/server", () => ({ + generateAuthenticationOptions: generateAuthenticationOptionsMock, + generateRegistrationOptions: mock(), + verifyAuthenticationResponse: mock(), + verifyRegistrationResponse: mock(), +})); + +const { POST } = await import("./route"); + +function makeRequest(body: Record): Request { + return new Request( + "https://auth.rxlab.app/api/oauth/passkey/authenticate/options", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/oauth/passkey/authenticate/options", () => { + beforeEach(() => { + findClient.mockReset(); + findUser.mockReset(); + findManyPasskeys.mockReset(); + storeChallengeMock.mockReset(); + generateAuthenticationOptionsMock.mockReset(); + + findClient.mockResolvedValue(VALID_CLIENT); + findUser.mockResolvedValue(undefined); + findManyPasskeys.mockResolvedValue([]); + storeChallengeMock.mockResolvedValue(undefined); + generateAuthenticationOptionsMock.mockResolvedValue({ + challenge: "challenge-stub", + rpId: "rxlab.app", + allowCredentials: [], + timeout: 60000, + userVerification: "preferred", + }); + }); + + test("happy path: returns options + sessionId; persists redirect_uri on challenge", 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(body.challenge).toBe("challenge-stub"); + expect(typeof body.sessionId).toBe("string"); + expect(storeChallengeMock).toHaveBeenCalledTimes(1); + const storedChallenge = storeChallengeMock.mock.calls[0][1]; + expect(storedChallenge.redirectUri).toBe("rxauthswift://callback"); + expect(storedChallenge.clientId).toBe(VALID_CLIENT.id); + expect(storedChallenge.type).toBe("native-authentication"); + }); + + test("invalid_request: missing redirect_uri", async () => { + const res = await POST( + makeRequest({ client_id: VALID_CLIENT.id }) as never, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.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"); + expect(storeChallengeMock).not.toHaveBeenCalled(); + }); + + 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("happy path: no longer requires signInPermission === 'all'", async () => { + findClient.mockResolvedValue({ + ...VALID_CLIENT, + signInPermission: "whitelist", + }); + const res = await POST( + makeRequest({ + client_id: VALID_CLIENT.id, + redirect_uri: "rxauthswift://callback", + }) as never, + ); + expect(res.status).toBe(200); + }); +}); diff --git a/app/api/oauth/passkey/authenticate/options/route.ts b/app/api/oauth/passkey/authenticate/options/route.ts index 54651fb..d42622b 100644 --- a/app/api/oauth/passkey/authenticate/options/route.ts +++ b/app/api/oauth/passkey/authenticate/options/route.ts @@ -12,7 +12,7 @@ import { parseTransports, } from "@/lib/webauthn/config"; import { passkeyAuthOptionsRequestSchema } from "@/lib/validations/oauth"; -import { validateNativeClient } from "@/lib/oauth/native-client"; +import { validateClientRedirect } from "@/lib/oauth/native-client"; // POST /api/oauth/passkey/authenticate/options // @@ -41,7 +41,10 @@ export async function POST(request: NextRequest) { ); } - const check = await validateNativeClient(parsed.data.client_id); + const check = await validateClientRedirect({ + clientId: parsed.data.client_id, + redirectUri: parsed.data.redirect_uri, + }); if (!check.ok) return check.response; const sessionId = crypto.randomUUID(); @@ -73,6 +76,7 @@ export async function POST(request: NextRequest) { challenge: authenticationOptions.challenge, type: "native-authentication", clientId: parsed.data.client_id, + redirectUri: parsed.data.redirect_uri, createdAt: Date.now(), }; await storeWebAuthnChallenge(sessionId, challengeData); diff --git a/app/api/oauth/passkey/authenticate/verify/route.test.ts b/app/api/oauth/passkey/authenticate/verify/route.test.ts index 8f12245..c2c40ce 100644 --- a/app/api/oauth/passkey/authenticate/verify/route.test.ts +++ b/app/api/oauth/passkey/authenticate/verify/route.test.ts @@ -42,10 +42,13 @@ const VALID_PASSKEY = { lastUsedAt: null, }; +const VALID_REDIRECT_URI = "rxauthswift://callback"; + const VALID_CHALLENGE = { challenge: "challenge-stub", type: "native-authentication" as const, clientId: VALID_CLIENT.id, + redirectUri: VALID_REDIRECT_URI, createdAt: Date.now(), }; @@ -175,14 +178,25 @@ describe("POST /api/oauth/passkey/authenticate/verify", () => { expect((await res.json()).error).toBe("invalid_client"); }); - test("unauthorized_client: rejects client with signInPermission != all", async () => { + test("invalid_request: stored redirect_uri no longer in client's allow-list", async () => { + // Simulates admin removing the URI from the client between options & verify. findClient.mockResolvedValue({ ...VALID_CLIENT, - signInPermission: "whitelist", + redirectUris: JSON.stringify(["some://other-callback"]), }); const res = await POST(makeRequest(VALID_BODY) as never); expect(res.status).toBe(400); - expect((await res.json()).error).toBe("unauthorized_client"); + const body = await res.json(); + expect(body.error).toBe("invalid_request"); + expect(body.error_description).toBe("Invalid redirect_uri for client"); + }); + + test("invalid_request: challenge has no redirectUri (older / corrupt)", async () => { + getChallengeMock.mockResolvedValue({ ...VALID_CHALLENGE, redirectUri: undefined }); + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("invalid_request"); }); test("invalid_grant: no challenge stored for session_id", async () => { @@ -226,6 +240,31 @@ describe("POST /api/oauth/passkey/authenticate/verify", () => { expect((await res.json()).error).toBe("invalid_grant"); }); + test("happy path: native macOS assertion with bare-rpId origin verifies", async () => { + // Simulate iOS/macOS native flow where ASAuthorizationServices sets the + // WebAuthn origin in clientDataJSON to the bare rpId origin + // (e.g. https://rxlab.app) rather than the server's subdomain. + // The route should pass an array of expected origins to SimpleWebAuthn, + // so the bare-rpId origin is accepted. + let capturedExpectedOrigin: unknown; + verifyAuthenticationResponseMock.mockImplementation( + async (args: { expectedOrigin: unknown }) => { + capturedExpectedOrigin = args.expectedOrigin; + return { + verified: true, + authenticationInfo: { newCounter: 1 }, + }; + }, + ); + + const res = await POST(makeRequest(VALID_BODY) as never); + expect(res.status).toBe(200); + expect(Array.isArray(capturedExpectedOrigin)).toBe(true); + expect(capturedExpectedOrigin as string[]).toContain( + "http://localhost:3000", + ); + }); + test("invalid_scope: scope outside client's allowed_scopes", async () => { const res = await POST( makeRequest({ ...VALID_BODY, scope: "openid admin:all" }) as never, diff --git a/app/api/oauth/passkey/authenticate/verify/route.ts b/app/api/oauth/passkey/authenticate/verify/route.ts index b7c3a7a..7356bcd 100644 --- a/app/api/oauth/passkey/authenticate/verify/route.ts +++ b/app/api/oauth/passkey/authenticate/verify/route.ts @@ -4,10 +4,10 @@ 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 { rpID, expectedOrigins, base64UrlDecode } from "@/lib/webauthn/config"; import { passkeyAuthVerifyRequestSchema } from "@/lib/validations/oauth"; import { - validateNativeClient, + validateClientRedirect, resolveRequestedScopes, } from "@/lib/oauth/native-client"; import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; @@ -41,14 +41,6 @@ export async function POST(request: NextRequest) { } 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 || @@ -64,6 +56,20 @@ export async function POST(request: NextRequest) { ); } + // Re-validate the redirect_uri persisted on the challenge against the + // client's current allow-list (defense-in-depth: catches the case where + // the client's registered URIs were edited between options and 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 passkey = await db.query.passkeys.findFirst({ where: eq(passkeys.id, data.credential.id), }); @@ -91,7 +97,7 @@ export async function POST(request: NextRequest) { typeof verifyAuthenticationResponse >[0]["response"], expectedChallenge: challengeData.challenge, - expectedOrigin: origin, + expectedOrigin: expectedOrigins, expectedRPID: rpID, credential: { id: passkey.id, diff --git a/app/api/oauth/passkey/register/options/route.test.ts b/app/api/oauth/passkey/register/options/route.test.ts new file mode 100644 index 0000000..073de8a --- /dev/null +++ b/app/api/oauth/passkey/register/options/route.test.ts @@ -0,0 +1,145 @@ +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 findExistingUser = mock(); +const storeChallengeMock = mock(); +const checkSignUpAllowedMock = mock(); +const generateRegistrationOptionsMock = mock(); + +mock.module("@/lib/db", () => ({ + db: { + query: { + oauthClients: { findFirst: findClient }, + users: { findFirst: findExistingUser }, + }, + }, +})); + +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/register/options", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/oauth/passkey/register/options", () => { + beforeEach(() => { + findClient.mockReset(); + findExistingUser.mockReset(); + storeChallengeMock.mockReset(); + checkSignUpAllowedMock.mockReset(); + generateRegistrationOptionsMock.mockReset(); + + findClient.mockResolvedValue(VALID_CLIENT); + findExistingUser.mockResolvedValue(undefined); + storeChallengeMock.mockResolvedValue(undefined); + checkSignUpAllowedMock.mockResolvedValue({ allowed: true }); + generateRegistrationOptionsMock.mockResolvedValue({ + challenge: "challenge-stub", + rp: { id: "rxlab.app", name: "RxLab Auth" }, + user: { id: "uid", name: "user@example.com", displayName: "user" }, + pubKeyCredParams: [], + attestation: "none", + }); + }); + + test("happy path: returns options + sessionId; persists redirect_uri on challenge", async () => { + const res = await POST( + makeRequest({ + client_id: VALID_CLIENT.id, + redirect_uri: "rxauthswift://callback", + username: "new@example.com", + }) as never, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.sessionId).toBe("string"); + expect(storeChallengeMock).toHaveBeenCalledTimes(1); + const storedChallenge = storeChallengeMock.mock.calls[0][1]; + expect(storedChallenge.redirectUri).toBe("rxauthswift://callback"); + expect(storedChallenge.clientId).toBe(VALID_CLIENT.id); + expect(storedChallenge.type).toBe("native-registration"); + expect(storedChallenge.pendingEmail).toBe("new@example.com"); + }); + + test("invalid_request: missing redirect_uri", async () => { + const res = await POST( + makeRequest({ + client_id: VALID_CLIENT.id, + username: "new@example.com", + }) 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", + username: "new@example.com", + }) 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"); + expect(storeChallengeMock).not.toHaveBeenCalled(); + }); + + test("invalid_client: unknown client_id", async () => { + findClient.mockResolvedValue(undefined); + const res = await POST( + makeRequest({ + client_id: "ghost", + redirect_uri: "rxauthswift://callback", + username: "new@example.com", + }) as never, + ); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe("invalid_client"); + }); +}); diff --git a/app/api/oauth/passkey/register/options/route.ts b/app/api/oauth/passkey/register/options/route.ts index d1dccfa..b02808e 100644 --- a/app/api/oauth/passkey/register/options/route.ts +++ b/app/api/oauth/passkey/register/options/route.ts @@ -9,7 +9,7 @@ import { } from "@/lib/redis"; import { getRegistrationOptions } from "@/lib/webauthn/config"; import { passkeyRegisterOptionsRequestSchema } from "@/lib/validations/oauth"; -import { validateNativeClient } from "@/lib/oauth/native-client"; +import { validateClientRedirect } from "@/lib/oauth/native-client"; import { checkSignUpAllowed } from "@/lib/settings/sign-up"; // POST /api/oauth/passkey/register/options @@ -41,7 +41,10 @@ export async function POST(request: NextRequest) { } const data = parsed.data; - const clientCheck = await validateNativeClient(data.client_id); + const clientCheck = await validateClientRedirect({ + clientId: data.client_id, + redirectUri: data.redirect_uri, + }); if (!clientCheck.ok) return clientCheck.response; const email = data.username.toLowerCase(); @@ -86,6 +89,7 @@ export async function POST(request: NextRequest) { userId: pendingUserId, type: "native-registration", clientId: data.client_id, + redirectUri: data.redirect_uri, pendingEmail: email, pendingDisplayName: displayName, createdAt: Date.now(), diff --git a/app/api/oauth/passkey/register/verify/route.test.ts b/app/api/oauth/passkey/register/verify/route.test.ts index 6be44cf..cfdad6e 100644 --- a/app/api/oauth/passkey/register/verify/route.test.ts +++ b/app/api/oauth/passkey/register/verify/route.test.ts @@ -19,11 +19,14 @@ const VALID_CLIENT = { const PENDING_USER_ID = "pending-user-id"; const PENDING_EMAIL = "newbie@example.com"; +const VALID_REDIRECT_URI = "rxauthswift://callback"; + const VALID_CHALLENGE = { challenge: "challenge-stub", userId: PENDING_USER_ID, type: "native-registration" as const, clientId: VALID_CLIENT.id, + redirectUri: VALID_REDIRECT_URI, pendingEmail: PENDING_EMAIL, pendingDisplayName: "Newbie", createdAt: Date.now(), @@ -172,14 +175,16 @@ describe("POST /api/oauth/passkey/register/verify", () => { expect((await res.json()).error).toBe("invalid_client"); }); - test("unauthorized_client: rejects client with signInPermission != all", async () => { + test("invalid_request: stored redirect_uri no longer in client's allow-list", async () => { findClient.mockResolvedValue({ ...VALID_CLIENT, - signInPermission: "none", + redirectUris: JSON.stringify(["some://other-callback"]), }); const res = await POST(makeRequest(VALID_BODY) as never); expect(res.status).toBe(400); - expect((await res.json()).error).toBe("unauthorized_client"); + const body = await res.json(); + expect(body.error).toBe("invalid_request"); + expect(body.error_description).toBe("Invalid redirect_uri for client"); }); test("invalid_grant: missing challenge for session_id", async () => { diff --git a/app/api/oauth/passkey/register/verify/route.ts b/app/api/oauth/passkey/register/verify/route.ts index e22a90c..4f7be2c 100644 --- a/app/api/oauth/passkey/register/verify/route.ts +++ b/app/api/oauth/passkey/register/verify/route.ts @@ -4,11 +4,11 @@ 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 { rpID, expectedOrigins, base64UrlEncode } from "@/lib/webauthn/config"; import { generateAvatarSeed } from "@/lib/identicon/generate"; import { passkeyRegisterVerifyRequestSchema } from "@/lib/validations/oauth"; import { - validateNativeClient, + validateClientRedirect, resolveRequestedScopes, } from "@/lib/oauth/native-client"; import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; @@ -46,14 +46,6 @@ export async function POST(request: NextRequest) { } 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 || @@ -71,6 +63,19 @@ export async function POST(request: NextRequest) { ); } + // Re-validate the redirect_uri persisted on the challenge against the + // client's current allow-list (defense-in-depth). + 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; + // 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), @@ -93,7 +98,7 @@ export async function POST(request: NextRequest) { typeof verifyRegistrationResponse >[0]["response"], expectedChallenge: challengeData.challenge, - expectedOrigin: origin, + expectedOrigin: expectedOrigins, expectedRPID: rpID, }); } catch (error) { diff --git a/lib/oauth/native-client.ts b/lib/oauth/native-client.ts index 7d5c24f..22f8677 100644 --- a/lib/oauth/native-client.ts +++ b/lib/oauth/native-client.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { oauthClients } from "@/lib/db/schema"; +import { matchRedirectUri } from "@/lib/oauth/redirect-uri"; type Client = typeof oauthClients.$inferSelect; @@ -46,6 +47,59 @@ export async function validateNativeClient( return { ok: true, client }; } +// Validate that a client exists and that the supplied redirect_uri is one +// the client has registered. Used by passkey native routes — instead of +// requiring `signInPermission === "all"`, we trust the client's registered +// redirect-URI allow-list (same gate authorization_code already uses, see +// app/api/oauth/authorize/route.ts:78-87). +// +// On unknown client: 401 invalid_client. +// On missing or unregistered redirect_uri: 400 invalid_request. +export async function validateClientRedirect(params: { + clientId: string; + redirectUri: string | undefined; +}): Promise { + const { clientId, redirectUri } = params; + 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 (!redirectUri) { + return { + ok: false, + response: NextResponse.json( + { + error: "invalid_request", + error_description: "Invalid redirect_uri for client", + }, + { status: 400 }, + ), + }; + } + const allowed: string[] = JSON.parse(client.redirectUris); + if (!matchRedirectUri(redirectUri, allowed)) { + return { + ok: false, + response: NextResponse.json( + { + error: "invalid_request", + error_description: "Invalid redirect_uri for client", + }, + { 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( diff --git a/lib/redis/index.ts b/lib/redis/index.ts index cc891fd..068e7c0 100644 --- a/lib/redis/index.ts +++ b/lib/redis/index.ts @@ -52,6 +52,10 @@ export interface WebAuthnChallengeData { createdAt: number; // Native (OAuth) passkey flows pin the challenge to a specific client. clientId?: string; + // Native passkey flows: redirect_uri the OPTIONS request validated against. + // Stored so VERIFY can re-validate it against the client's current + // redirect-URI allow-list (defense-in-depth). + redirectUri?: string; // Native registration only: pending user-to-be-created pendingEmail?: string; pendingDisplayName?: string; diff --git a/lib/validations/oauth.test.ts b/lib/validations/oauth.test.ts index 6b058a1..17c8732 100644 --- a/lib/validations/oauth.test.ts +++ b/lib/validations/oauth.test.ts @@ -312,6 +312,7 @@ describe("passkeyAuthOptionsRequestSchema", () => { test("accepts minimal body", () => { const result = passkeyAuthOptionsRequestSchema.safeParse({ client_id: "macos-test-app", + redirect_uri: "rxauthswift://callback", }); expect(result.success).toBe(true); }); @@ -319,6 +320,7 @@ describe("passkeyAuthOptionsRequestSchema", () => { test("accepts optional username (email)", () => { const result = passkeyAuthOptionsRequestSchema.safeParse({ client_id: "macos-test-app", + redirect_uri: "rxauthswift://callback", username: "user@example.com", }); expect(result.success).toBe(true); @@ -327,13 +329,23 @@ describe("passkeyAuthOptionsRequestSchema", () => { test("rejects non-email username", () => { const result = passkeyAuthOptionsRequestSchema.safeParse({ client_id: "macos-test-app", + redirect_uri: "rxauthswift://callback", username: "not-an-email", }); expect(result.success).toBe(false); }); test("rejects missing client_id", () => { - const result = passkeyAuthOptionsRequestSchema.safeParse({}); + const result = passkeyAuthOptionsRequestSchema.safeParse({ + redirect_uri: "rxauthswift://callback", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing redirect_uri", () => { + const result = passkeyAuthOptionsRequestSchema.safeParse({ + client_id: "macos-test-app", + }); expect(result.success).toBe(false); }); }); @@ -371,6 +383,7 @@ describe("passkeyRegisterOptionsRequestSchema", () => { test("accepts minimal body", () => { const result = passkeyRegisterOptionsRequestSchema.safeParse({ client_id: "macos-test-app", + redirect_uri: "rxauthswift://callback", username: "user@example.com", }); expect(result.success).toBe(true); @@ -379,6 +392,7 @@ describe("passkeyRegisterOptionsRequestSchema", () => { test("rejects non-email username", () => { const result = passkeyRegisterOptionsRequestSchema.safeParse({ client_id: "macos-test-app", + redirect_uri: "rxauthswift://callback", username: "not-an-email", }); expect(result.success).toBe(false); @@ -386,6 +400,15 @@ describe("passkeyRegisterOptionsRequestSchema", () => { test("rejects missing client_id", () => { const result = passkeyRegisterOptionsRequestSchema.safeParse({ + redirect_uri: "rxauthswift://callback", + username: "user@example.com", + }); + expect(result.success).toBe(false); + }); + + test("rejects missing redirect_uri", () => { + const result = passkeyRegisterOptionsRequestSchema.safeParse({ + client_id: "macos-test-app", username: "user@example.com", }); expect(result.success).toBe(false); diff --git a/lib/validations/oauth.ts b/lib/validations/oauth.ts index 0e737f0..883b120 100644 --- a/lib/validations/oauth.ts +++ b/lib/validations/oauth.ts @@ -104,6 +104,7 @@ export const passkeyAttestationSchema = z // POST /api/oauth/passkey/authenticate/options export const passkeyAuthOptionsRequestSchema = z.object({ client_id: z.string().min(1, "client_id is required"), + redirect_uri: z.string().min(1, "redirect_uri is required"), username: z.string().email().optional(), }); @@ -118,6 +119,7 @@ export const passkeyAuthVerifyRequestSchema = z.object({ // POST /api/oauth/passkey/register/options export const passkeyRegisterOptionsRequestSchema = z.object({ client_id: z.string().min(1, "client_id is required"), + redirect_uri: z.string().min(1, "redirect_uri is required"), username: z.string().email("username must be a valid email address"), name: z.string().min(1).max(64).optional(), }); diff --git a/lib/webauthn/config.ts b/lib/webauthn/config.ts index 575c2b3..11cf686 100644 --- a/lib/webauthn/config.ts +++ b/lib/webauthn/config.ts @@ -7,6 +7,26 @@ export const rpID = process.env.WEBAUTHN_RP_ID || "localhost"; export const rpName = process.env.WEBAUTHN_RP_NAME || "RxLab Auth"; export const origin = process.env.WEBAUTHN_ORIGIN || "http://localhost:3000"; +// Origins SimpleWebAuthn's verify functions will accept. +// +// Always includes the configured web origin (e.g. https://auth.rxlab.app). +// Also includes https:// (e.g. https://rxlab.app) when the rpID is a +// real registrable domain — native iOS/macOS apps using an Associated Domain +// of webcredentials: set the WebAuthn origin in clientDataJSON to that +// bare-rpId URL, not the server's subdomain. Dedupes; skips for localhost. +export function computeExpectedOrigins( + configuredOrigin: string, + rpId: string, +): string[] { + const set = new Set([configuredOrigin]); + if (rpId && rpId !== "localhost" && rpId.includes(".")) { + set.add(`https://${rpId}`); + } + return [...set]; +} + +export const expectedOrigins: string[] = computeExpectedOrigins(origin, rpID); + export function getRegistrationOptions( userId: string, userEmail: string, diff --git a/lib/webauthn/expected-origins.test.ts b/lib/webauthn/expected-origins.test.ts new file mode 100644 index 0000000..430da2a --- /dev/null +++ b/lib/webauthn/expected-origins.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { computeExpectedOrigins } from "./config"; + +describe("computeExpectedOrigins", () => { + test("prod: includes both configured origin and bare-rpId origin", () => { + const origins = computeExpectedOrigins( + "https://auth.rxlab.app", + "rxlab.app", + ); + expect(origins).toContain("https://auth.rxlab.app"); + expect(origins).toContain("https://rxlab.app"); + expect(origins.length).toBe(2); + }); + + test("localhost dev: only configured origin, no https://localhost", () => { + const origins = computeExpectedOrigins( + "http://localhost:3000", + "localhost", + ); + expect(origins).toEqual(["http://localhost:3000"]); + }); + + test("dedupes when configured origin already equals https://", () => { + const origins = computeExpectedOrigins( + "https://rxlab.app", + "rxlab.app", + ); + expect(origins).toEqual(["https://rxlab.app"]); + }); + + test("rpId without a dot is treated like localhost (no bare origin added)", () => { + const origins = computeExpectedOrigins("http://localhost:3000", "myhost"); + expect(origins).toEqual(["http://localhost:3000"]); + }); +});