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
2 changes: 2 additions & 0 deletions .changeset/protect-check-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ClerkAPIResponseError } from '@/error';
import type { ProtectCheckResource } from '@/types';

import type { ProtectCheckRunOptions } from '../protectCheckRunner';
import { MAX_EXPIRED_PROTECT_CHECK_RELOADS, runProtectCheck } from '../protectCheckRunner';

vi.mock('../protectCheck', () => ({
executeProtectCheck: vi.fn(),
}));

import { executeProtectCheck } from '../protectCheck';

const mockExecute = vi.mocked(executeProtectCheck);

type Resource = { id: string; protectCheck: ProtectCheckResource | null };

const challenge = (overrides: Partial<ProtectCheckResource> = {}): ProtectCheckResource => ({
status: 'pending',
token: 'challenge-token',
sdkUrl: 'https://protect.example.com/sdk.js',
...overrides,
});

const alreadyResolved = () =>
new ClerkAPIResponseError('already resolved', {
status: 400,
data: [{ code: 'protect_check_already_resolved', message: 'already resolved' }],
});

const setup = (initial: ProtectCheckResource | null = challenge()) => {
const live: Resource = { id: 'live', protectCheck: initial };
const submitted: Resource = { id: 'submitted', protectCheck: null };
const ops = {
getProtectCheck: vi.fn(() => live.protectCheck),
getResource: vi.fn(() => live),
reload: vi.fn(() => Promise.resolve()),
submitProtectCheck: vi.fn(() => Promise.resolve(submitted)),
};
const container = document.createElement('div');
const expiredReloads = { current: 0 };
const run = (check: ProtectCheckResource, options: Partial<ProtectCheckRunOptions> = {}) =>
runProtectCheck<Resource>(ops, check, { container, expiredReloads, ...options });
return { live, submitted, ops, container, expiredReloads, run };
};

beforeEach(() => {
mockExecute.mockReset();
mockExecute.mockResolvedValue('proof-abc');
});

describe('ProtectCheckRunner', () => {
it('executes the challenge in the container and submits the proof token', async () => {
const { run, ops, container, submitted } = setup();
const signal = new AbortController().signal;
const setWidgetVisible = vi.fn(() => Promise.resolve());

const outcome = await run(challenge(), { container, signal, setWidgetVisible, loadTimeoutMs: 1234 });

expect(mockExecute).toHaveBeenCalledWith(expect.objectContaining({ token: 'challenge-token' }), container, {
signal,
setWidgetVisible,
loadTimeoutMs: 1234,
});
expect(ops.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' });
expect(outcome).toEqual({ status: 'resolved', resource: submitted });
});

it('does nothing when the signal is already aborted, even for an expired challenge', async () => {
const { run, ops, container } = setup();
const controller = new AbortController();
controller.abort();

await expect(
run(challenge({ expiresAt: Date.now() - 1000 }), { container, signal: controller.signal }),
).rejects.toMatchObject({ code: 'protect_check_aborted' });
expect(ops.reload).not.toHaveBeenCalled();
expect(mockExecute).not.toHaveBeenCalled();
});

it('does not submit a proof token that arrives after the signal aborted', async () => {
const { run, ops, container } = setup();
const controller = new AbortController();
mockExecute.mockImplementation(() => {
controller.abort();
return Promise.resolve('late-proof');
});

await expect(run(challenge(), { container, signal: controller.signal })).rejects.toMatchObject({
code: 'protect_check_aborted',
});
expect(ops.submitProtectCheck).not.toHaveBeenCalled();
});

it('does not submit when the challenge script fails', async () => {
const { run, ops, container } = setup();
mockExecute.mockRejectedValue(new Error('script failed'));

await expect(run(challenge(), { container })).rejects.toThrow('script failed');
expect(ops.submitProtectCheck).not.toHaveBeenCalled();
});

it('treats protect_check_already_resolved as resolved after a reload', async () => {
const { run, ops, container, live } = setup();
ops.submitProtectCheck.mockRejectedValue(alreadyResolved());
ops.reload.mockImplementation(() => {
live.protectCheck = null;
return Promise.resolve();
});

const outcome = await run(challenge(), { container });

expect(ops.reload).toHaveBeenCalledTimes(1);
expect(outcome).toEqual({ status: 'resolved', resource: live });
});

it('does not reload or resolve an already-resolved submit once the signal aborted', async () => {
const { run, ops, container } = setup();
const controller = new AbortController();
ops.submitProtectCheck.mockImplementation(() => {
controller.abort();
return Promise.reject(alreadyResolved());
});

await expect(run(challenge(), { container, signal: controller.signal })).rejects.toMatchObject({
code: 'protect_check_aborted',
});
expect(ops.reload).not.toHaveBeenCalled();
});

it('rethrows other submit errors without a reload', async () => {
const { run, ops, container } = setup();
ops.submitProtectCheck.mockRejectedValue(new Error('network'));

await expect(run(challenge(), { container })).rejects.toThrow('network');
expect(ops.reload).not.toHaveBeenCalled();
});

describe('an expired challenge', () => {
const expired = () => challenge({ expiresAt: Date.now() - 1000 });

it('reloads instead of executing and resolves when the reload clears the gate', async () => {
const { run, ops, container, live } = setup(expired());
ops.reload.mockImplementation(() => {
live.protectCheck = null;
return Promise.resolve();
});

const outcome = await run(expired(), { container });

expect(mockExecute).not.toHaveBeenCalled();
expect(outcome).toEqual({ status: 'resolved', resource: live });
});

it('reports reissued when the reload produced a fresh challenge', async () => {
const { run, ops, container, live } = setup(expired());
ops.reload.mockImplementation(() => {
live.protectCheck = challenge({ token: 'challenge-token-2', expiresAt: Date.now() + 60_000 });
return Promise.resolve();
});

const outcome = await run(expired(), { container });

expect(outcome).toEqual({ status: 'reissued' });
expect(mockExecute).not.toHaveBeenCalled();
});

it('fails with protect_check_timed_out when the reload returns the same expired challenge', async () => {
const { run, ops, container } = setup(expired());

await expect(run(expired(), { container })).rejects.toMatchObject({ code: 'protect_check_timed_out' });
expect(ops.reload).toHaveBeenCalledTimes(1);
});

it('stops reloading once the budget is spent and resumes after the caller resets it', async () => {
const { run, ops, container, expiredReloads } = setup(expired());

for (let i = 0; i < MAX_EXPIRED_PROTECT_CHECK_RELOADS; i++) {
await expect(run(expired(), { container })).rejects.toMatchObject({ code: 'protect_check_timed_out' });
}
await expect(run(expired(), { container })).rejects.toMatchObject({ code: 'protect_check_timed_out' });
expect(ops.reload).toHaveBeenCalledTimes(MAX_EXPIRED_PROTECT_CHECK_RELOADS);

expiredReloads.current = 0;
await expect(run(expired(), { container })).rejects.toMatchObject({ code: 'protect_check_timed_out' });
expect(ops.reload).toHaveBeenCalledTimes(MAX_EXPIRED_PROTECT_CHECK_RELOADS + 1);
});
});
});
90 changes: 90 additions & 0 deletions packages/shared/src/internal/clerk-js/protectCheckRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ClerkRuntimeError, isClerkAPIResponseError } from '../../error';
import type { ProtectCheckResource } from '../../types';
import { ERROR_CODES } from './constants';
import { executeProtectCheck } from './protectCheck';

// A GET reload does not re-mint an expired challenge today, so an uncapped reload loop would spin forever.
export const MAX_EXPIRED_PROTECT_CHECK_RELOADS = 2;

export interface ProtectCheckRunnerResource<TResource> {
getProtectCheck: () => ProtectCheckResource | null | undefined;
getResource: () => TResource;
reload: () => Promise<unknown>;
submitProtectCheck: (params: { proofToken: string }) => Promise<TResource>;
}

export interface ProtectCheckRunOptions {
container: HTMLDivElement;
expiredReloads: { current: number };
signal?: AbortSignal;
setWidgetVisible?: (visible: boolean) => Promise<void>;
loadTimeoutMs?: number;
}

/** `reissued` means the expired challenge was replaced by a fresh one on reload, so run again with it. */
export type ProtectCheckRunOutcome<TResource> = { status: 'resolved'; resource: TResource } | { status: 'reissued' };

const isExpired = (protectCheck: ProtectCheckResource) =>
protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now();

const expiredError = () =>
new ClerkRuntimeError('Protect verification expired', { code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT });

const abortedError = () => new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' });

const reloadExpired = async <TResource>(
resource: ProtectCheckRunnerResource<TResource>,
expiredReloads: { current: number },
): Promise<ProtectCheckRunOutcome<TResource>> => {
if (expiredReloads.current >= MAX_EXPIRED_PROTECT_CHECK_RELOADS) {
throw expiredError();
}
expiredReloads.current += 1;

await resource.reload();

const refreshed = resource.getProtectCheck();
if (!refreshed) {
return { status: 'resolved', resource: resource.getResource() };
}
if (isExpired(refreshed)) {
throw expiredError();
}
return { status: 'reissued' };
};

/** Runs one Protect challenge against a sign-in or sign-up resource and submits the proof token. */
export async function runProtectCheck<TResource>(
resource: ProtectCheckRunnerResource<TResource>,
protectCheck: ProtectCheckResource,
options: ProtectCheckRunOptions,
): Promise<ProtectCheckRunOutcome<TResource>> {
const { container, expiredReloads, signal, setWidgetVisible, loadTimeoutMs } = options;
if (signal?.aborted) {
throw abortedError();
}
if (isExpired(protectCheck)) {
return reloadExpired(resource, expiredReloads);
}

// Deliberately unraced. Only the module load is bounded. The challenge itself may wait on a
// person, and a timeout here used to abort valid challenges.
const proofToken = await executeProtectCheck(protectCheck, container, { signal, setWidgetVisible, loadTimeoutMs });
if (signal?.aborted) {
throw abortedError();
}

try {
const updated = await resource.submitProtectCheck({ proofToken });
return { status: 'resolved', resource: updated };
} catch (err) {
if (signal?.aborted) {
throw abortedError();
}
if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) {
await resource.reload();
return { status: 'resolved', resource: resource.getResource() };
}
throw err;
}
}
Loading
Loading