Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.nx
.yarn/*
!.yarn/patches
!.yarn/plugins
Expand Down
18 changes: 14 additions & 4 deletions dev/config/src/vite/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,28 @@ describe("ViteTestConfig", () => {
// the repo-root globs only match `packages/*/src/**`.
asPackage();
const config = ViteTestConfig();
expect(config.test?.coverage?.include).toContain("src/**/*.ts");
expect(config.test?.coverage?.exclude).toContain("src/**/*.test.ts");
// Vitest's coverage type is a discriminated union on `provider`; the
// custom-provider variant doesn't declare `include`/`exclude` even
// though they're valid at runtime for every provider. Cast to the v8
// variant (which does declare them) for assertion purposes only.
const coverage = config.test?.coverage as
| { include?: unknown; exclude?: unknown }
| undefined;
expect(coverage?.include).toContain("src/**/*.ts");
expect(coverage?.exclude).toContain("src/**/*.test.ts");
});

it("falls back to repo-wide globs when there is no src directory", () => {
asRepoRoot();
const config = ViteTestConfig();
expect(config.test?.coverage?.include).toEqual([
const coverage = config.test?.coverage as
| { include?: unknown; exclude?: unknown }
| undefined;
expect(coverage?.include).toEqual([
"packages/*/src/**",
"captcha/packages/*/src/**",
]);
expect(config.test?.coverage?.exclude).toContain("**/node_modules/**");
expect(coverage?.exclude).toContain("**/node_modules/**");
});

it("adds the tsconfig-paths plugin only when given a tsconfig", () => {
Expand Down
42 changes: 42 additions & 0 deletions packages/api/src/api/ProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,48 @@ export default class ProviderApi
});
}

/**
* Verify an authenticated (Web Bot Auth fast-path) session token. Wire
* shape matches the pow/image/puzzle verify calls — same `ip` binding, same
* optional `clientSessionId` correlation, so `@prosopo/server` can dispatch
* without a token-shape branch. Provider-side the router routes to
* `verifyAuthenticatedSession`, which additionally requires `ip` (a token
* minted for a signed request is meaningless without the IP it was issued
* to) and flips `serverChecked=true` on first use for single-consumption.
*/
public submitAuthenticatedCaptchaVerify(
token: string,
signatureHex: string,
user: string,
ip?: string,
email?: string,
clientSessionId?: string,
): Promise<VerificationResponse> {
const body: ServerPowCaptchaVerifyRequestBodyType = {
[ApiParams.token]: token,
[ApiParams.dappSignature]: signatureHex,
[ApiParams.ip]: ip,
};
// Email is a wire-parity passthrough: the authenticated flow has no
// email captured at issuance (Web Bot Auth doesn't carry one), so no
// spam-filter / correlation runs on it server-side. Forwarded anyway so
// operator logs and verdict rows on the provider stay symmetric with
// pow/image/puzzle and a customer's siteverify call doesn't have to
// know which captchaType the token was minted with.
if (email) {
body[ApiParams.email] = email;
}
if (clientSessionId) {
body[ApiParams.clientSessionId] = clientSessionId;
}
return this.post(ClientApiPaths.VerifyAuthenticatedSession, body, {
headers: {
"Prosopo-Site-Key": this.account,
"Prosopo-User": user,
},
});
}

public registerSiteKey(
siteKey: string,
tier: Tier,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/RateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ export const getRateLimitConfig = () => {
windowMs: process.env.PROSOPO_VERIFY_PUZZLE_CAPTCHA_SOLUTION_WINDOW,
limit: process.env.PROSOPO_VERIFY_PUZZLE_CAPTCHA_SOLUTION_LIMIT,
},
[ClientApiPaths.VerifyAuthenticatedSession]: {
windowMs: process.env.PROSOPO_VERIFY_AUTHENTICATED_SESSION_WINDOW,
limit: process.env.PROSOPO_VERIFY_AUTHENTICATED_SESSION_LIMIT,
},
[AdminApiPaths.DnsEvent]: {
windowMs: process.env.PROSOPO_DNS_EVENT_WINDOW,
limit: process.env.PROSOPO_DNS_EVENT_LIMIT,
Expand Down
141 changes: 141 additions & 0 deletions packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright 2021-2026 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Widget for the `authenticated` captcha outcome — no user-facing challenge,
// just a visible acknowledgement that Web Bot Auth verification succeeded.
// Mounts once, immediately encodes a ProcaptchaToken with the sessionId as
// its commitmentId (matching what /client/authenticated/verify decodes) and
// fires `onHuman`. The badge itself is purely presentational; the token
// submission happens in the mount effect.
//
// The token carries empty signature bags (`{provider: {}, user: {}}`) — the
// authenticated verify endpoint does not check the token's user or provider
// signatures because there was no captcha challenge to sign. Only the dApp
// server's signature on the timestamp is checked at verify time, and that
// is applied by the dApp on the way to /verify (not by the widget here).

import { getDefaultEvents } from "@prosopo/procaptcha-common";
import {
type Account,
ApiParams,
CaptchaType,
type ProcaptchaCallbacks,
type RandomProvider,
encodeProcaptchaOutput,
} from "@prosopo/types";
import { type FC, useEffect, useRef } from "react";

export type AuthenticatedBadgeProps = {
sessionId: string;
agent?: string;
dapp: string;
userAccount: Account;
provider: RandomProvider;
callbacks: ProcaptchaCallbacks;
};

const displayHost = (agent?: string): string => {
if (!agent) return "unknown";
try {
return new URL(agent).hostname;
} catch {
return agent;
}
};

export const AuthenticatedBadge: FC<AuthenticatedBadgeProps> = ({
sessionId,
agent,
dapp,
userAccount,
provider,
callbacks,
}) => {
// One-shot: React 18 StrictMode double-invokes effects in development;
// this guard makes sure the token is emitted exactly once even under
// double-mount. Production doesn't double-invoke, so this is a
// belt-and-braces against local-dev confusion.
const emittedRef = useRef(false);

useEffect(() => {
if (emittedRef.current) return;
emittedRef.current = true;
const events = getDefaultEvents(callbacks);
const token = encodeProcaptchaOutput({
[ApiParams.providerUrl]: provider.provider.url,
[ApiParams.user]: userAccount.account.address,
[ApiParams.dapp]: dapp,
// Verify endpoint reads commitmentId as the sessionId — reusing
// the existing slot avoids a token codec bump for this one flow.
[ApiParams.commitmentId]: sessionId,
[ApiParams.timestamp]: Date.now().toString(),
[ApiParams.signature]: {
[ApiParams.provider]: {},
[ApiParams.user]: {},
},
[ApiParams.captchaType]: CaptchaType.authenticated,
});
events.onHuman(token);
}, [sessionId, dapp, userAccount, provider, callbacks]);

return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 6,
background: "#eef2ff",
border: "1px solid #c7d2fe",
color: "#3730a3",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontSize: 13,
lineHeight: 1.4,
}}
// biome-ignore lint/a11y/useSemanticElements: doesn't make sense
role="status"
aria-label="Verified agent"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<title>Verified</title>
<path d="M20 6L9 17l-5-5" />
</svg>
<span>
{agent ? (
<>
<strong>Verified agent</strong>: {displayHost(agent)}
</>
) : (
// No Signature-Agent URL on the response — the operator's Allow
// rule matched on a non-Web-Bot-Auth condition (IP CIDR, JA4,
// UA substring, ASN, country). Fall back to generic copy so
// the operator isn't misled about which qualifier fired.
<strong>Trusted request</strong>
)}
</span>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "@prosopo/types";
import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
import { useEffect, useRef, useState } from "react";
import { AuthenticatedBadge } from "./AuthenticatedBadge.js";
import customDetectBot from "./customDetectBot.js";
import { evaluateFrictionlessResult } from "./frictionlessResultGuard.js";
import {
Expand Down Expand Up @@ -257,7 +258,31 @@ export const ProcaptchaFrictionless = ({
mountCountRef.current += 1;
const mountKey = mountCountRef.current;

if (captchaType === CaptchaType.image) {
if (captchaType === CaptchaType.authenticated) {
// Web Bot Auth pre-verified pass-through. No challenge, no
// interaction — the badge component encodes and fires the token
// on mount. Skip the loader chain the other branches use because
// there is no captcha module to lazy-import here.
if (!frictionlessState.sessionId) {
events.onError(
new Error(
"authenticated captcha response missing sessionId — provider is misbehaving",
),
);
fallOverWithStyle();
return;
}
setComponentToRender(
<AuthenticatedBadge
sessionId={frictionlessState.sessionId}
agent={frictionlessState.agent}
dapp={config.account.address ?? ""}
userAccount={frictionlessState.userAccount}
provider={frictionlessState.provider}
callbacks={callbacks}
/>,
);
} else if (captchaType === CaptchaType.image) {
const Procaptcha = await ProcaptchaLoader();
setComponentToRender(
<Procaptcha
Expand Down Expand Up @@ -353,6 +378,7 @@ export const ProcaptchaFrictionless = ({
encryptBehavioralData: result.encryptBehavioralData,
getSimdReadings: result.getSimdReadings,
hp: result.hp,
agent: result.agent,
};

await renderForCaptchaType(result.captchaType, frictionlessState);
Expand Down
2 changes: 2 additions & 0 deletions packages/procaptcha-frictionless/src/customDetectBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ const customDetectBot: BotDetectionFunction = async (
userAccount: userAccount,
error: captcha.error,
hp: captcha.hp,
agent: captcha.agent,
};
}

Expand Down Expand Up @@ -357,6 +358,7 @@ const customDetectBot: BotDetectionFunction = async (
userAccount: userAccount,
error: captcha.error,
hp: captcha.hp,
agent: captcha.agent,
// Map specific trackers to generic behavioral collectors
behaviorCollector1: detectionResult.mouseTracker,
behaviorCollector2: detectionResult.touchTracker,
Expand Down
1 change: 1 addition & 0 deletions packages/provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@prosopo/types-env": "2.10.38",
"@prosopo/user-access-policy": "3.12.27",
"@prosopo/util": "3.3.7",
"@prosopo/web-bot-auth": "0.0.1",
"@prosopo/util-crypto": "13.5.30",
"cron": "3.1.7",
"express": "4.22.2",
Expand Down
10 changes: 10 additions & 0 deletions packages/provider/src/api/blacklistRequestInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export const getRequestUserScope = (
coords?: string,
countryCode?: string,
asn?: number,
// Present only when Web Bot Auth signature verification succeeded on the
// inbound request. Passed through to rule matching so `webBotAuthAgent`
// rules match the verified signer URL, never a spoofed header.
webBotAuthAgent?: string,
): Pick<
UserScopeRecord,
| "userId"
Expand All @@ -65,6 +69,7 @@ export const getRequestUserScope = (
| "countryCode"
| "asn"
| "os"
| "webBotAuthAgent"
> => {
const userAgent = requestHeaders["user-agent"]
? requestHeaders["user-agent"].toString()
Expand All @@ -79,6 +84,7 @@ export const getRequestUserScope = (
...(coords && { coords }),
...(countryCode && { countryCode }),
...(typeof asn === "number" && { asn }),
...(webBotAuthAgent && { webBotAuthAgent }),
// Always populated (even "unknown") — derived from the request UA, not
// trusted from a client hint. Present unconditionally so an OS
// allow-list (block everything not on the list) still matches requests
Expand All @@ -100,6 +106,7 @@ const SCALAR_USER_SCOPE_FIELDS = [
"countryCode",
"asn",
"os",
"webBotAuthAgent",
] as const satisfies ReadonlyArray<keyof UserScope>;

// Derive the populated-scope field list for a matched rule (the same shape
Expand Down Expand Up @@ -223,6 +230,9 @@ const CAPTCHA_TYPE_HARSHNESS: Record<CaptchaType, number> = {
// if the enum grows. Restrict-with-frictionless wouldn't make operational
// sense and ranks at the bottom of the captcha tiers if it ever appears.
[CaptchaType.frictionless]: 0,
// authenticated is a pre-verified pass-through — never a Restrict target.
// Rank at 0 alongside frictionless for the same "should never rank" reason.
[CaptchaType.authenticated]: 0,
};

// Harshness within an equal-specificity tier (issue #3713). On equal
Expand Down
Loading
Loading