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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/api/admin/passkey/authenticate/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions app/api/admin/passkey/register/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});

Expand Down
4 changes: 2 additions & 2 deletions app/api/auth/passkey/authenticate/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/api/auth/passkey/register/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});

Expand Down
151 changes: 151 additions & 0 deletions app/api/oauth/passkey/authenticate/options/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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);
});
});
8 changes: 6 additions & 2 deletions app/api/oauth/passkey/authenticate/options/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 42 additions & 3 deletions app/api/oauth/passkey/authenticate/verify/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 17 additions & 11 deletions app/api/oauth/passkey/authenticate/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ||
Expand All @@ -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),
});
Expand Down Expand Up @@ -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,
Expand Down
Loading