Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .changeset/passkeys-section-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
31 changes: 6 additions & 25 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import type {
UserJSON,
UserOrganizationInvitationJSON,
} from '@clerk/shared/types';
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';

import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise';
import { type FakePasskeysSeed, passkeyHandlers } from './fake-fapi/passkeys';
import { envelope, error, findSession, missing, updateUser } from './fake-fapi/shared';
import {
createVerificationState,
type FakeVerificationSeed,
Expand Down Expand Up @@ -54,6 +56,7 @@ export interface FakeFapiState {
export type FakeFapiSeed = Partial<Omit<FakeFapiState, 'verification' | 'enterpriseLinking'>> & {
verification?: FakeVerificationSeed;
enterpriseLinking?: Partial<FakeEnterpriseLinking>;
passkeys?: FakePasskeysSeed;
};

const unhandled: string[] = [];
Expand All @@ -75,10 +78,6 @@ export function takeUnhandledRequests(): string[] {
return unhandled.splice(0);
}

function envelope(response: JsonBodyType, client: ClientJSON | null) {
return HttpResponse.json({ response, client });
}

function page<T>(items: T[], url: URL) {
const offset = Number(url.searchParams.get('offset') ?? 0);
const limit = Number(url.searchParams.get('limit') ?? items.length);
Expand All @@ -90,25 +89,10 @@ function withStatus<T extends { status: string }>(items: T[], url: URL): T[] {
return statuses.length ? items.filter(item => statuses.includes(item.status)) : items;
}

function findSession(state: FakeFapiState, id: unknown): SessionJSON | undefined {
return state.client.sessions.find(session => session.id === id);
}

function error(code: string, status = 400) {
return HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status });
}

function activeUser(state: FakeFapiState): UserJSON | undefined {
return findSession(state, state.client.last_active_session_id)?.user;
}

function updateUser(state: FakeFapiState, user: UserJSON): void {
state.client = {
...state.client,
sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)),
};
}

function withoutOrganization(session: SessionJSON, organizationId: string): SessionJSON {
return {
...session,
Expand Down Expand Up @@ -137,12 +121,8 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): void
};
}

function missing() {
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
}

export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const { verification, enterpriseLinking, ...rest } = seed;
const { verification, enterpriseLinking, passkeys, ...rest } = seed;
const state: FakeFapiState = {
environment: fapiEnvironment(),
client: fapiClient(),
Expand All @@ -166,6 +146,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
worker.use(
...verificationHandlers(state, fapiUrl),
...enterpriseHandlers(state, fapiUrl),
...passkeyHandlers(state, fapiUrl, passkeys),
http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)),
http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)),
http.get(fapiUrl('/v1/me'), () => {
Expand Down
159 changes: 159 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi/passkeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { PasskeyJSON } from '@clerk/shared/types';
import { http, HttpResponse } from 'msw';

import type { FakeFapiState } from '../fake-fapi';
import { fapiPasskey } from '../fapi';
import { envelope, error, missing, requestUser, updateUser } from './shared';

export interface FakePasskeysSeed {
name?: string;
authenticatorName?: string;
}

export function passkeyHandlers(
state: Pick<FakeFapiState, 'client' | 'environment'>,
fapiUrl: (path: string) => string,
seed: FakePasskeysSeed = {},
) {
const pendingPasskeys = new Map<string, { userId: string; passkey: PasskeyJSON; expiresAt: number }>();
let nextPasskeyId = 1;

return [
http.post(fapiUrl('/v1/me/passkeys'), ({ request }) => {
const user = requestUser(state, request);
if (!user) {
return missing();
}
if (!state.environment.user_settings.attributes.passkey.enabled) {
return error('feature_not_enabled', 403);
}
if (
state.environment.user_settings.enterprise_sso.enabled &&
user.enterprise_accounts.some(
account =>
account.enterprise_connection?.active && account.enterprise_connection.disable_additional_identifications,
)
) {
return error('enterprise_sso_additional_identifications_disabled', 422);
}
if (user.passkeys.length >= 10) {
return error('passkey_quota_exceeded', 403);
}
const now = Date.now();
for (const [id, pending] of pendingPasskeys) {
if (pending.userId === user.id && pending.expiresAt <= now) {
pendingPasskeys.delete(id);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const expiresAt = now + 10 * 60_000;
const passkey = fapiPasskey({
id: `passkey_${nextPasskeyId++}`,
name: seed.name ?? 'Chrome on macOS',
verification: {
id: 'verification_passkey',
object: 'verification',
status: 'unverified',
verified_at_client: '',
strategy: 'passkey',
attempts: 0,
expire_at: expiresAt,
error: { code: '', message: '' },
nonce: JSON.stringify({
challenge: 'Y2hhbGxlbmdl',
rp: { name: 'Acme', id: 'localhost' },
user: { id: 'dXNlcg', name: 'user@example.com', displayName: 'Test user' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
}),
},
});
pendingPasskeys.set(passkey.id, { userId: user.id, passkey, expiresAt });
return envelope(passkey, state.client);
}),
http.post(fapiUrl('/v1/me/passkeys/:id/attempt_verification'), async ({ params, request }) => {
const pending = typeof params.id === 'string' ? pendingPasskeys.get(params.id) : undefined;
const user = requestUser(state, request);
if (!user || !pending) {
return missing();
}
if (pending.userId !== user.id) {
return error('resource_forbidden', 403);
}
const { passkey } = pending;
const verification = passkey.verification;
if (!verification || pending.expiresAt <= Date.now()) {
return error('verification_expired', 400);
}
const body = new URLSearchParams(await request.text());
if (body.get('strategy') !== 'passkey' || !body.get('public_key_credential')) {
return HttpResponse.json(
{ errors: [{ code: 'form_param_missing', message: 'Passkey credential required.' }] },
{ status: 400 },
);
}
const verified = {
...passkey,
name: seed.authenticatorName || passkey.name,
last_used_at: Date.now(),
verification: {
...verification,
status: 'verified' as const,
attempts: verification.attempts + 1,
verified_at_client: state.client.id,
nonce: undefined,
error: undefined,
},
};
pendingPasskeys.delete(passkey.id);
updateUser(state, { ...user, passkeys: [...user.passkeys, verified] });
return envelope(verified, state.client);
}),
http.post(fapiUrl('/v1/me/passkeys/:id'), async ({ params, request }) => {
const user = requestUser(state, request);
if (!user || typeof params.id !== 'string') {
return missing();
}
const pending = pendingPasskeys.get(params.id);
const passkey =
user.passkeys.find(candidate => candidate.id === params.id) ??
(pending?.userId === user.id ? pending.passkey : undefined);
if (!passkey) {
return missing();
}
const method = new URL(request.url).searchParams.get('_method');
if (method === 'DELETE') {
pendingPasskeys.delete(passkey.id);
updateUser(state, { ...user, passkeys: user.passkeys.filter(candidate => candidate.id !== passkey.id) });
return envelope({ object: 'passkey', id: passkey.id, deleted: true }, state.client);
}
if (method === 'PATCH') {
const body = new URLSearchParams(await request.text());
const name = body.get('name');
if (name !== null && new TextEncoder().encode(name).length > 256) {
return HttpResponse.json(
{
errors: [
{
code: 'form_param_max_length_exceeded',
message: 'Passkey name is too long.',
meta: { param_name: 'name' },
},
],
},
{ status: 422 },
);
}
const renamed = { ...passkey, name: name ?? passkey.name };
if (pending?.userId === user.id) {
pendingPasskeys.set(passkey.id, { ...pending, passkey: renamed });
return envelope(renamed, state.client);
}
updateUser(state, {
...user,
passkeys: user.passkeys.map(candidate => (candidate.id === passkey.id ? renamed : candidate)),
});
return envelope(renamed, state.client);
}
return missing();
}),
];
}
32 changes: 32 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ClientJSON, SessionJSON, UserJSON } from '@clerk/shared/types';
import { HttpResponse, type JsonBodyType } from 'msw';

type ClientState = { client: ClientJSON };

export function envelope(response: JsonBodyType, client: ClientJSON | null) {
return HttpResponse.json({ response, client });
}

export function findSession(state: ClientState, id: unknown): SessionJSON | undefined {
return state.client.sessions.find(session => session.id === id);
}

export function error(code: string, status = 400) {
return HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status });
}

export function requestUser(state: ClientState, request: Request): UserJSON | undefined {
const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
return findSession(state, sessionId)?.user;
}

export function updateUser(state: ClientState, user: UserJSON): void {
state.client = {
...state.client,
sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)),
};
}

export function missing() {
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
}
13 changes: 13 additions & 0 deletions packages/mosaic/src/__tests__/feature/fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
OrganizationMembershipJSON,
OrganizationSettingsJSON,
OrganizationSuggestionJSON,
PasskeyJSON,
PublicKeyCredentialRequestOptionsJSON,
PublicOrganizationDataJSON,
SessionJSON,
Expand Down Expand Up @@ -549,3 +550,15 @@ export function fapiApiKey(overrides: Partial<ApiKeyJSON> & Pick<ApiKeyJSON, 'id
export function fapiPage<T>(data: T[], totalCount = data.length): FapiPage<T> {
return { data, total_count: totalCount };
}

export function fapiPasskey(overrides: Partial<PasskeyJSON> & Pick<PasskeyJSON, 'id'>): PasskeyJSON {
return {
object: 'passkey',
name: 'Laptop',
verification: null,
created_at: createdAt,
updated_at: createdAt,
last_used_at: null,
...overrides,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section';

function serveRegistration(passkeys = { name: 'Chrome on macOS', authenticatorName: '' }) {
const environment = fapiEnvironment();
environment.user_settings.attributes.passkey.enabled = true;
vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false);
vi.spyOn(navigator.credentials, 'create').mockResolvedValue({
id: 'credential_1',
type: 'public-key',
rawId: new Uint8Array([1, 2, 3]).buffer,
authenticatorAttachment: 'platform',
response: {
clientDataJSON: new TextEncoder().encode('{}').buffer,
attestationObject: new Uint8Array([4, 5, 6]).buffer,
getTransports: () => ['internal'],
},
});
const seed = {
environment,
passkeys,
client: fapiClient([
fapiSession({ id: 'sess_a', user: fapiUser({ id: 'user_a' }) }),
fapiSession({ id: 'sess_b', user: fapiUser({ id: 'user_b' }) }),
]),
};
return serveFapi(seed);
}

afterEach(() => vi.restoreAllMocks());

describe('Registering passkeys against the backend contract', () => {
it('rejects another user verifying a pending registration without changing either account', async () => {
const fapi = serveRegistration();
const { clerk } = await renderWithClerk(<UserProfilePasskeysSection />);
const alice = clerk.user;
if (!alice) {
throw new Error('Missing Alice');
}
const creation = holdRequests('post', '/v1/me/passkeys');
const outcome = alice.createPasskey().then(
result => result,
(error: unknown) => error,
);
await waitFor(() => expect(creation.requests).toHaveLength(1));
await act(() => clerk.setActive({ session: 'sess_b' }));
creation.release();
expect(await outcome).toMatchObject({ status: 403, errors: [{ code: 'resource_forbidden' }] });
expect(fapi.client.sessions.map(session => session.user.passkeys)).toEqual([[], []]);
expect(screen.queryByText('Chrome on macOS')).toBeNull();
});

it.each([
{ minutes: 2, expired: false },
{ minutes: 10, expired: true },
])('handles verification after $minutes minutes', async ({ minutes, expired }) => {
const fapi = serveRegistration();
await renderWithClerk(<UserProfilePasskeysSection />);
const verification = holdRequests('post', '/v1/me/passkeys/passkey_1/attempt_verification');
await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
await waitFor(() => expect(verification.requests).toHaveLength(1));
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + minutes * 60_000);
verification.release();
if (expired) {
expect(await screen.findByRole('alert')).toBeVisible();
expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]);
} else {
expect(await screen.findByText('Chrome on macOS')).toBeVisible();
expect(screen.queryByRole('alert')).toBeNull();
}
});

it.each([
{ name: 'Firefox on Windows', authenticatorName: '', expected: 'Firefox on Windows' },
{ name: 'Chrome on macOS', authenticatorName: 'iCloud Keychain', expected: 'iCloud Keychain' },
])('shows $expected after registration', async ({ name, authenticatorName, expected }) => {
const fapi = serveRegistration({ name, authenticatorName });
await renderWithClerk(<UserProfilePasskeysSection />);
await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' }));
expect(await screen.findByText(expected)).toBeVisible();
expect(fapi.client.sessions[0]?.user.passkeys[0]?.name).toBe(expected);
});
});
Loading
Loading