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
84 changes: 84 additions & 0 deletions app/api/oauth/passkey/authenticate/options/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users, passkeys } from "@/lib/db/schema";
import {
storeWebAuthnChallenge,
type WebAuthnChallengeData,
} from "@/lib/redis";
import {
getAuthenticationOptions,
parseTransports,
} from "@/lib/webauthn/config";
import { passkeyAuthOptionsRequestSchema } from "@/lib/validations/oauth";
import { validateNativeClient } from "@/lib/oauth/native-client";

// POST /api/oauth/passkey/authenticate/options
//
// Native passkey sign-in: returns a WebAuthn assertion options blob + a
// session id that the macOS client passes back to /verify. No cookie or
// pre-existing session is required.
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "invalid_request", error_description: "Body must be JSON" },
{ status: 400 },
);
}

const parsed = passkeyAuthOptionsRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "invalid_request",
error_description: parsed.error.issues[0]?.message,
},
{ status: 400 },
);
}

const check = await validateNativeClient(parsed.data.client_id);
if (!check.ok) return check.response;

const sessionId = crypto.randomUUID();

let allowCredentials: {
id: string;
transports?: AuthenticatorTransport[];
}[] = [];

if (parsed.data.username) {
const user = await db.query.users.findFirst({
where: eq(users.email, parsed.data.username.toLowerCase()),
});
if (user) {
const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id),
});
allowCredentials = userPasskeys.map((pk) => ({
id: pk.id,
transports: parseTransports(pk.transports),
}));
}
}

const options = getAuthenticationOptions(allowCredentials);
const authenticationOptions = await generateAuthenticationOptions(options);

const challengeData: WebAuthnChallengeData = {
challenge: authenticationOptions.challenge,
type: "native-authentication",
clientId: parsed.data.client_id,
createdAt: Date.now(),
};
await storeWebAuthnChallenge(sessionId, challengeData);

return NextResponse.json({
...authenticationOptions,
sessionId,
});
}
236 changes: 236 additions & 0 deletions app/api/oauth/passkey/authenticate/verify/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { describe, expect, test, mock, beforeEach } from "bun:test";

const VALID_CLIENT = {
id: "macos-test-app",
clientType: "public" as const,
secret: null as string | null,
name: "macOS Test App",
description: null,
iconUrl: null,
redirectUris: JSON.stringify(["rxauthswift://callback"]),
allowedScopes: JSON.stringify(["openid", "read:profile", "read:email"]),
isFirstParty: true,
signInPermission: "all" as const,
permissions: null,
createdAt: new Date(),
updatedAt: new Date(),
};

const VALID_USER = {
id: "user-1",
email: "user@example.com",
emailVerified: true,
passwordHash: null,
username: null,
displayName: "Test User",
avatarSeed: null,
avatarUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
};

const VALID_PASSKEY = {
id: "cred-id",
userId: "user-1",
name: "MacBook",
publicKey: "base64url-pubkey",
counter: 0,
deviceType: "platform" as const,
backedUp: true,
transports: null,
createdAt: new Date(),
lastUsedAt: null,
};

const VALID_CHALLENGE = {
challenge: "challenge-stub",
type: "native-authentication" as const,
clientId: VALID_CLIENT.id,
createdAt: Date.now(),
};

const findClient = mock();
const findUser = mock();
const findPasskey = mock();
const getChallengeMock = mock();
const deleteChallengeMock = mock();
const verifyAuthenticationResponseMock = mock();
const signAccessTokenMock = mock();
const signIdTokenMock = mock();
const generateRefreshTokenMock = mock();
const insertValues = mock();
const updateSet = mock();

mock.module("@/lib/db", () => ({
db: {
query: {
oauthClients: { findFirst: findClient },
users: { findFirst: findUser },
passkeys: { findFirst: findPasskey },
},
insert: () => ({ values: insertValues }),
update: () => ({ set: updateSet }),
},
}));

mock.module("@/lib/redis", () => ({
getWebAuthnChallenge: getChallengeMock,
deleteWebAuthnChallenge: deleteChallengeMock,
storeWebAuthnChallenge: mock(),
getOAuthCode: mock(),
deleteOAuthCode: mock(),
storeOAuthCode: mock(),
}));

mock.module("@simplewebauthn/server", () => ({
verifyAuthenticationResponse: verifyAuthenticationResponseMock,
generateAuthenticationOptions: mock(),
generateRegistrationOptions: mock(),
verifyRegistrationResponse: mock(),
}));

mock.module("@/lib/oauth/jwt", () => ({
signAccessToken: signAccessTokenMock,
signIdToken: signIdTokenMock,
generateRefreshToken: generateRefreshTokenMock,
}));

const { POST } = await import("./route");

function makeRequest(body: Record<string, unknown>): Request {
return new Request(
"https://auth.rxlab.app/api/oauth/passkey/authenticate/verify",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}

const VALID_BODY = {
client_id: VALID_CLIENT.id,
session_id: "session-id",
credential: {
id: "cred-id",
rawId: "cred-id",
type: "public-key",
response: {
clientDataJSON: "abc",
authenticatorData: "def",
signature: "ghi",
userHandle: "user-handle",
},
},
scope: "openid read:email",
};

describe("POST /api/oauth/passkey/authenticate/verify", () => {
beforeEach(() => {
findClient.mockReset();
findUser.mockReset();
findPasskey.mockReset();
getChallengeMock.mockReset();
deleteChallengeMock.mockReset();
verifyAuthenticationResponseMock.mockReset();
signAccessTokenMock.mockReset();
signIdTokenMock.mockReset();
generateRefreshTokenMock.mockReset();
insertValues.mockReset();
updateSet.mockReset();

findClient.mockResolvedValue(VALID_CLIENT);
findUser.mockResolvedValue(VALID_USER);
findPasskey.mockResolvedValue(VALID_PASSKEY);
getChallengeMock.mockResolvedValue(VALID_CHALLENGE);
deleteChallengeMock.mockResolvedValue(undefined);
verifyAuthenticationResponseMock.mockResolvedValue({
verified: true,
authenticationInfo: { newCounter: 1 },
});
signAccessTokenMock.mockResolvedValue("access-token-stub");
signIdTokenMock.mockResolvedValue("id-token-stub");
generateRefreshTokenMock.mockReturnValue("refresh-token-stub");
insertValues.mockResolvedValue(undefined);
updateSet.mockReturnValue({ where: () => Promise.resolve() });
});

test("happy path: issues tokens matching /api/oauth/token shape", async () => {
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.access_token).toBe("access-token-stub");
expect(body.refresh_token).toBe("refresh-token-stub");
expect(body.id_token).toBe("id-token-stub");
expect(body.token_type).toBe("Bearer");
expect(body.expires_in).toBe(3600);
expect(body.scope).toBe("openid read:email");
expect(deleteChallengeMock).toHaveBeenCalledWith("session-id");
});

test("invalid_client: rejects unknown client_id", async () => {
findClient.mockResolvedValue(undefined);
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe("invalid_client");
});

test("unauthorized_client: rejects client with signInPermission != all", async () => {
findClient.mockResolvedValue({
...VALID_CLIENT,
signInPermission: "whitelist",
});
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("unauthorized_client");
});

test("invalid_grant: no challenge stored for session_id", async () => {
getChallengeMock.mockResolvedValue(null);
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
});

test("invalid_grant: challenge belongs to different client", async () => {
getChallengeMock.mockResolvedValue({
...VALID_CHALLENGE,
clientId: "different-client",
});
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
});

test("invalid_grant: challenge has wrong type (e.g. browser registration)", async () => {
getChallengeMock.mockResolvedValue({
...VALID_CHALLENGE,
type: "registration",
});
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
});

test("invalid_grant: unknown credential id", async () => {
findPasskey.mockResolvedValue(undefined);
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
});

test("invalid_grant: WebAuthn verification fails", async () => {
verifyAuthenticationResponseMock.mockResolvedValue({ verified: false });
const res = await POST(makeRequest(VALID_BODY) as never);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_grant");
});

test("invalid_scope: scope outside client's allowed_scopes", async () => {
const res = await POST(
makeRequest({ ...VALID_BODY, scope: "openid admin:all" }) as never,
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("invalid_scope");
});
});
Loading