diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py index cb33ff4d2c01..959d30df00af 100644 --- a/authentik/stages/captcha/api.py +++ b/authentik/stages/captcha/api.py @@ -17,6 +17,7 @@ class Meta: "private_key", "js_url", "api_url", + "request_content_type", "interactive", "score_min_threshold", "score_max_threshold", diff --git a/authentik/stages/captcha/migrations/0005_captchastage_request_content_type.py b/authentik/stages/captcha/migrations/0005_captchastage_request_content_type.py new file mode 100644 index 000000000000..6c7d24dbe7df --- /dev/null +++ b/authentik/stages/captcha/migrations/0005_captchastage_request_content_type.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.14 on 2026-05-14 23:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_captcha", "0004_captchastage_interactive"), + ] + + operations = [ + migrations.AddField( + model_name="captchastage", + name="request_content_type", + field=models.TextField( + choices=[ + ("application/x-www-form-urlencoded", "Form encoded"), + ("application/json", "JSON"), + ], + default="application/x-www-form-urlencoded", + ), + ), + ] diff --git a/authentik/stages/captcha/models.py b/authentik/stages/captcha/models.py index fb5a6dac2893..776911b91ead 100644 --- a/authentik/stages/captcha/models.py +++ b/authentik/stages/captcha/models.py @@ -8,6 +8,13 @@ from authentik.flows.models import Stage +class CaptchaRequestContentType(models.TextChoices): + """Supported request content types for CAPTCHA verification.""" + + FORM = "application/x-www-form-urlencoded", _("Form encoded") + JSON = "application/json", _("JSON") + + class CaptchaStage(Stage): """Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions.""" @@ -30,6 +37,10 @@ class CaptchaStage(Stage): js_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api.js") api_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api/siteverify") + request_content_type = models.TextField( + choices=CaptchaRequestContentType.choices, + default=CaptchaRequestContentType.FORM, + ) @property def serializer(self) -> type[BaseSerializer]: diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py index 9e582e561449..66b823e7a771 100644 --- a/authentik/stages/captcha/stage.py +++ b/authentik/stages/captcha/stage.py @@ -15,7 +15,7 @@ from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.http import get_http_session from authentik.root.middleware import ClientIPMiddleware -from authentik.stages.captcha.models import CaptchaStage +from authentik.stages.captcha.models import CaptchaRequestContentType, CaptchaStage LOGGER = get_logger() PLAN_CONTEXT_CAPTCHA = "captcha" @@ -35,17 +35,23 @@ class CaptchaChallenge(WithUserInfoChallenge): def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str, key: str | None = None): """Validate captcha token""" + payload = { + "secret": key or stage.private_key, + "response": token, + "remoteip": remote_ip, + } + body_kwargs = ( + {"json": payload} + if stage.request_content_type == CaptchaRequestContentType.JSON + else {"data": payload} + ) try: response = get_http_session().post( stage.api_url, headers={ - "Content-type": "application/x-www-form-urlencoded", - }, - data={ - "secret": key or stage.private_key, - "response": token, - "remoteip": remote_ip, + "Content-Type": stage.request_content_type, }, + **body_kwargs, ) response.raise_for_status() data = response.json() diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py index 41bceb9f43a3..39a7b4a32e57 100644 --- a/authentik/stages/captcha/tests.py +++ b/authentik/stages/captcha/tests.py @@ -10,7 +10,7 @@ from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.generators import generate_id -from authentik.stages.captcha.models import CaptchaStage +from authentik.stages.captcha.models import CaptchaRequestContentType, CaptchaStage from authentik.stages.captcha.stage import ( PLAN_CONTEXT_CAPTCHA_PRIVATE_KEY, PLAN_CONTEXT_CAPTCHA_SITE_KEY, @@ -56,6 +56,39 @@ def test_valid(self, mock: Mocker): ) self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + self.assertEqual( + mock.request_history[0].headers["Content-Type"], + CaptchaRequestContentType.FORM, + ) + self.assertIn("response=PASSED", mock.request_history[0].text) + + @Mocker() + def test_valid_json_content_type(self, mock: Mocker): + """Test valid captcha with JSON verification request""" + self.stage.request_content_type = CaptchaRequestContentType.JSON + self.stage.save() + mock.post( + "https://www.recaptcha.net/recaptcha/api/siteverify", + json={ + "success": True, + "score": 0.5, + }, + ) + plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"token": "PASSED"}, + ) + self.assertEqual(response.status_code, 200) + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + self.assertEqual( + mock.request_history[0].headers["Content-Type"], + CaptchaRequestContentType.JSON, + ) + self.assertEqual(mock.request_history[0].json()["response"], "PASSED") @Mocker() def test_valid_override(self, mock: Mocker): diff --git a/blueprints/schema.json b/blueprints/schema.json index 38e266f92424..a32288d3ba4a 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -15160,6 +15160,14 @@ "minLength": 1, "title": "Api url" }, + "request_content_type": { + "type": "string", + "enum": [ + "application/x-www-form-urlencoded", + "application/json" + ], + "title": "Request content type" + }, "interactive": { "type": "boolean", "title": "Interactive" diff --git a/packages/client-ts/src/models/CaptchaStage.ts b/packages/client-ts/src/models/CaptchaStage.ts index 2c6b18c9dc50..13430ab177fa 100644 --- a/packages/client-ts/src/models/CaptchaStage.ts +++ b/packages/client-ts/src/models/CaptchaStage.ts @@ -14,6 +14,11 @@ import type { FlowSet } from "./FlowSet"; import { FlowSetFromJSON } from "./FlowSet"; +import type { RequestContentTypeEnum } from "./RequestContentTypeEnum"; +import { + RequestContentTypeEnumFromJSON, + RequestContentTypeEnumToJSON, +} from "./RequestContentTypeEnum"; /** * CaptchaStage Serializer @@ -81,6 +86,12 @@ export interface CaptchaStage { * @memberof CaptchaStage */ apiUrl?: string; + /** + * + * @type {RequestContentTypeEnum} + * @memberof CaptchaStage + */ + requestContentType?: RequestContentTypeEnum; /** * * @type {boolean} @@ -141,6 +152,10 @@ export function CaptchaStageFromJSONTyped(json: any, ignoreDiscriminator: boolea publicKey: json["public_key"], jsUrl: json["js_url"] == null ? undefined : json["js_url"], apiUrl: json["api_url"] == null ? undefined : json["api_url"], + requestContentType: + json["request_content_type"] == null + ? undefined + : RequestContentTypeEnumFromJSON(json["request_content_type"]), interactive: json["interactive"] == null ? undefined : json["interactive"], scoreMinThreshold: json["score_min_threshold"] == null ? undefined : json["score_min_threshold"], @@ -171,6 +186,7 @@ export function CaptchaStageToJSONTyped( public_key: value["publicKey"], js_url: value["jsUrl"], api_url: value["apiUrl"], + request_content_type: RequestContentTypeEnumToJSON(value["requestContentType"]), interactive: value["interactive"], score_min_threshold: value["scoreMinThreshold"], score_max_threshold: value["scoreMaxThreshold"], diff --git a/packages/client-ts/src/models/CaptchaStageRequest.ts b/packages/client-ts/src/models/CaptchaStageRequest.ts index 1c6802216861..37792dc7ef2c 100644 --- a/packages/client-ts/src/models/CaptchaStageRequest.ts +++ b/packages/client-ts/src/models/CaptchaStageRequest.ts @@ -12,6 +12,12 @@ * Do not edit the class manually. */ +import type { RequestContentTypeEnum } from "./RequestContentTypeEnum"; +import { + RequestContentTypeEnumFromJSON, + RequestContentTypeEnumToJSON, +} from "./RequestContentTypeEnum"; + /** * CaptchaStage Serializer * @export @@ -48,6 +54,12 @@ export interface CaptchaStageRequest { * @memberof CaptchaStageRequest */ apiUrl?: string; + /** + * + * @type {RequestContentTypeEnum} + * @memberof CaptchaStageRequest + */ + requestContentType?: RequestContentTypeEnum; /** * * @type {boolean} @@ -101,6 +113,10 @@ export function CaptchaStageRequestFromJSONTyped( privateKey: json["private_key"], jsUrl: json["js_url"] == null ? undefined : json["js_url"], apiUrl: json["api_url"] == null ? undefined : json["api_url"], + requestContentType: + json["request_content_type"] == null + ? undefined + : RequestContentTypeEnumFromJSON(json["request_content_type"]), interactive: json["interactive"] == null ? undefined : json["interactive"], scoreMinThreshold: json["score_min_threshold"] == null ? undefined : json["score_min_threshold"], @@ -129,6 +145,7 @@ export function CaptchaStageRequestToJSONTyped( private_key: value["privateKey"], js_url: value["jsUrl"], api_url: value["apiUrl"], + request_content_type: RequestContentTypeEnumToJSON(value["requestContentType"]), interactive: value["interactive"], score_min_threshold: value["scoreMinThreshold"], score_max_threshold: value["scoreMaxThreshold"], diff --git a/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts b/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts index b616986c41c6..b2522615bb88 100644 --- a/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts +++ b/packages/client-ts/src/models/PatchedCaptchaStageRequest.ts @@ -12,6 +12,12 @@ * Do not edit the class manually. */ +import type { RequestContentTypeEnum } from "./RequestContentTypeEnum"; +import { + RequestContentTypeEnumFromJSON, + RequestContentTypeEnumToJSON, +} from "./RequestContentTypeEnum"; + /** * CaptchaStage Serializer * @export @@ -48,6 +54,12 @@ export interface PatchedCaptchaStageRequest { * @memberof PatchedCaptchaStageRequest */ apiUrl?: string; + /** + * + * @type {RequestContentTypeEnum} + * @memberof PatchedCaptchaStageRequest + */ + requestContentType?: RequestContentTypeEnum; /** * * @type {boolean} @@ -100,6 +112,10 @@ export function PatchedCaptchaStageRequestFromJSONTyped( privateKey: json["private_key"] == null ? undefined : json["private_key"], jsUrl: json["js_url"] == null ? undefined : json["js_url"], apiUrl: json["api_url"] == null ? undefined : json["api_url"], + requestContentType: + json["request_content_type"] == null + ? undefined + : RequestContentTypeEnumFromJSON(json["request_content_type"]), interactive: json["interactive"] == null ? undefined : json["interactive"], scoreMinThreshold: json["score_min_threshold"] == null ? undefined : json["score_min_threshold"], @@ -128,6 +144,7 @@ export function PatchedCaptchaStageRequestToJSONTyped( private_key: value["privateKey"], js_url: value["jsUrl"], api_url: value["apiUrl"], + request_content_type: RequestContentTypeEnumToJSON(value["requestContentType"]), interactive: value["interactive"], score_min_threshold: value["scoreMinThreshold"], score_max_threshold: value["scoreMaxThreshold"], diff --git a/packages/client-ts/src/models/RequestContentTypeEnum.ts b/packages/client-ts/src/models/RequestContentTypeEnum.ts new file mode 100644 index 000000000000..e287dc0fa258 --- /dev/null +++ b/packages/client-ts/src/models/RequestContentTypeEnum.ts @@ -0,0 +1,58 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.8.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + */ +export const RequestContentTypeEnum = { + ApplicationXWwwFormUrlencoded: "application/x-www-form-urlencoded", + ApplicationJson: "application/json", + UnknownDefaultOpenApi: "11184809", +} as const; +export type RequestContentTypeEnum = + (typeof RequestContentTypeEnum)[keyof typeof RequestContentTypeEnum]; + +export function instanceOfRequestContentTypeEnum(value: any): boolean { + for (const key in RequestContentTypeEnum) { + if (Object.prototype.hasOwnProperty.call(RequestContentTypeEnum, key)) { + if (RequestContentTypeEnum[key as keyof typeof RequestContentTypeEnum] === value) { + return true; + } + } + } + return false; +} + +export function RequestContentTypeEnumFromJSON(json: any): RequestContentTypeEnum { + return RequestContentTypeEnumFromJSONTyped(json, false); +} + +export function RequestContentTypeEnumFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): RequestContentTypeEnum { + return json as RequestContentTypeEnum; +} + +export function RequestContentTypeEnumToJSON(value?: RequestContentTypeEnum | null): any { + return value as any; +} + +export function RequestContentTypeEnumToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): RequestContentTypeEnum { + return value as RequestContentTypeEnum; +} diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index da4090a4cf8c..3f9fc870ef19 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -713,6 +713,7 @@ export * from "./RelatedRule"; export * from "./Reputation"; export * from "./ReputationPolicy"; export * from "./ReputationPolicyRequest"; +export * from "./RequestContentTypeEnum"; export * from "./Review"; export * from "./ReviewRequest"; export * from "./ReviewerGroup"; diff --git a/schema.yml b/schema.yml index 9714bf47c022..bc78dd4af27d 100644 --- a/schema.yml +++ b/schema.yml @@ -36339,6 +36339,8 @@ components: type: string api_url: type: string + request_content_type: + $ref: '#/components/schemas/RequestContentTypeEnum' interactive: type: boolean score_min_threshold: @@ -36384,6 +36386,8 @@ components: api_url: type: string minLength: 1 + request_content_type: + $ref: '#/components/schemas/RequestContentTypeEnum' interactive: type: boolean score_min_threshold: @@ -48287,6 +48291,8 @@ components: api_url: type: string minLength: 1 + request_content_type: + $ref: '#/components/schemas/RequestContentTypeEnum' interactive: type: boolean score_min_threshold: @@ -53868,6 +53874,11 @@ components: minimum: -2147483648 required: - name + RequestContentTypeEnum: + enum: + - application/x-www-form-urlencoded + - application/json + type: string Review: type: object description: |- diff --git a/web/src/admin/stages/captcha/CaptchaStageForm.ts b/web/src/admin/stages/captcha/CaptchaStageForm.ts index bb184313c6b1..f31b952bd6c7 100644 --- a/web/src/admin/stages/captcha/CaptchaStageForm.ts +++ b/web/src/admin/stages/captcha/CaptchaStageForm.ts @@ -14,9 +14,11 @@ import { SlottedTemplateResult } from "#elements/types"; import { BaseStageForm } from "#admin/stages/BaseStageForm"; import { CAPTCHA_PROVIDERS, + CAPTCHA_REQUEST_CONTENT_TYPES, CaptchaProviderKey, CaptchaProviderKeys, CaptchaProviderPreset, + deriveCapSiteVerifyURL, detectProviderFromInstance, pluckFormValues, } from "#admin/stages/captcha/shared"; @@ -35,6 +37,10 @@ import { customElement, state } from "lit/decorators.js"; import { guard } from "lit/directives/guard.js"; import { ifDefined } from "lit/directives/if-defined.js"; +type CaptchaStageFormRequest = (CaptchaStageRequest | PatchedCaptchaStageRequest) & { + capEndpoint?: string; +}; + @customElement("ak-stage-captcha-form") export class CaptchaStageForm extends BaseStageForm { public static override readonly styles = [...super.styles, Styles]; @@ -83,6 +89,26 @@ export class CaptchaStageForm extends BaseStageForm { public async send( data: CaptchaStageRequest | PatchedCaptchaStageRequest, ): Promise { + const formData = data as CaptchaStageFormRequest; + + if (this.selectedProvider === "cap" && (formData.capEndpoint || formData.publicKey)) { + const capEndpoint = formData.capEndpoint || formData.publicKey || ""; + + formData.publicKey = capEndpoint; + delete formData.capEndpoint; + + const presetURL = CAPTCHA_PROVIDERS.cap.apiUrl; + // The Cap verification URL includes the site key, so derive it from the + // widget endpoint unless the advanced field was explicitly customized. + if (!data.apiUrl || data.apiUrl === presetURL) { + const siteVerifyURL = deriveCapSiteVerifyURL(capEndpoint); + + if (siteVerifyURL) { + data.apiUrl = siteVerifyURL; + } + } + } + if (this.instance) { return this.#api.stagesCaptchaPartialUpdate({ stageUuid: this.instance.pk || "", @@ -117,43 +143,77 @@ export class CaptchaStageForm extends BaseStageForm {

${guard([this.#currentPreset], () => { - const { formatAPISource, keyURL } = this.#currentPreset; - - if (!formatAPISource || !keyURL) { - return null; - } - - return html` - ${msg( - html`API keys can be obtained from the - ${html`${formatAPISource()}.`}`, - { - id: "captcha.provider-link", - desc: "Supplementary help text with link to provider dashboard.", - }, - )} - `; + const { formatAPISource, formatDescription, keyURL } = this.#currentPreset; + + const description = formatDescription + ? html`

${formatDescription()}

` + : null; + const providerLink = + formatAPISource && keyURL + ? html` + ${this.selectedProvider === "cap" + ? msg( + html`Use the + ${html`${formatAPISource()}`} + to self-host Cap and configure the endpoint.`, + { + id: "captcha.provider-link.cap", + desc: "Supplementary help text with link to Cap documentation.", + }, + ) + : msg( + html`API keys can be obtained from the + ${html`${formatAPISource()}.`}`, + { + id: "captcha.provider-link", + desc: "Supplementary help text with link to provider dashboard.", + }, + )} + ` + : null; + + return html`${description} ${providerLink}`; })} `; } protected renderKeyFields(): SlottedTemplateResult { + const isCapProvider = this.selectedProvider === "cap"; + const publicKeyLabel = isCapProvider ? msg("Cap Endpoint") : msg("Public Key"); + const publicKeyPlaceholder = isCapProvider + ? msg("https://cap.example.com/site-key/") + : msg("Paste your CAPTCHA public key..."); + const publicKeyHelp = isCapProvider + ? msg("The public site-key endpoint of your Cap server.", { + id: "captcha.cap-endpoint.description", + desc: "Description for Cap endpoint field.", + }) + : msg("The public key is used by authentik to render the CAPTCHA widget.", { + id: "captcha.public-key.description", + desc: "Description for CAPTCHA public key field.", + }); + return html` @@ -246,10 +306,35 @@ export class CaptchaStageForm extends BaseStageForm { type="url" value="${ifDefined(formValues.apiUrl)}" required - help=${msg( - "URL used to validate CAPTCHA response on the backend. Automatically set based on provider selection but can be customized.", - )} + help=${this.selectedProvider === "cap" + ? msg( + "Cap's server-side verification endpoint, for example https://cap.example.com/site-key/siteverify.", + ) + : msg( + "URL used to validate CAPTCHA response on the backend. Automatically set based on provider selection but can be customized.", + )} > + + +

+ ${msg( + "Content-Type used for server-side verification. Cap requires JSON; most other providers use form-encoded requests.", + )} +

+
`; } diff --git a/web/src/admin/stages/captcha/shared.ts b/web/src/admin/stages/captcha/shared.ts index ce1031856962..7623f6db899f 100644 --- a/web/src/admin/stages/captcha/shared.ts +++ b/web/src/admin/stages/captcha/shared.ts @@ -2,12 +2,35 @@ import { CaptchaStage, CaptchaStageRequest } from "@goauthentik/api"; import { msg } from "@lit/localize"; +export type CaptchaRequestContentType = "application/x-www-form-urlencoded" | "application/json"; + +export const CAPTCHA_REQUEST_CONTENT_TYPES = [ + { + value: "application/x-www-form-urlencoded", + formatDisplayName: () => + msg("Form encoded", { + id: "captcha.request-content-type.form", + }), + }, + { + value: "application/json", + formatDisplayName: () => + msg("JSON", { + id: "captcha.request-content-type.json", + }), + }, +] as const satisfies { + value: CaptchaRequestContentType; + formatDisplayName: () => string; +}[]; + export const CaptchaProviderKeys = [ "recaptcha_v2", "recaptcha_v3", "recaptcha_enterprise", "hcaptcha", "turnstile", + "cap", "custom", ] as const satisfies string[]; @@ -15,8 +38,10 @@ export type CaptchaProviderKey = (typeof CaptchaProviderKeys)[number]; export interface CaptchaProviderPreset { formatDisplayName: () => string; + formatDescription?: () => string; jsUrl: string; apiUrl: string; + requestContentType: CaptchaRequestContentType; interactive: boolean; supportsScore: boolean; score?: { min: number; max: number }; @@ -37,6 +62,7 @@ export const CAPTCHA_PROVIDERS = { }), jsUrl: "https://www.recaptcha.net/recaptcha/api.js", apiUrl: "https://www.recaptcha.net/recaptcha/api/siteverify", + requestContentType: "application/x-www-form-urlencoded", interactive: true, supportsScore: false, formatAPISource: () => @@ -52,6 +78,7 @@ export const CAPTCHA_PROVIDERS = { }), jsUrl: "https://www.recaptcha.net/recaptcha/api.js", apiUrl: "https://www.recaptcha.net/recaptcha/api/siteverify", + requestContentType: "application/x-www-form-urlencoded", interactive: false, supportsScore: true, score: { min: 0.5, max: 1.0 }, @@ -68,6 +95,7 @@ export const CAPTCHA_PROVIDERS = { }), jsUrl: "https://www.recaptcha.net/recaptcha/enterprise.js", apiUrl: "https://www.recaptcha.net/recaptcha/api/siteverify", + requestContentType: "application/x-www-form-urlencoded", interactive: false, supportsScore: true, score: { min: 0.5, max: 1.0 }, @@ -84,6 +112,7 @@ export const CAPTCHA_PROVIDERS = { }), jsUrl: "https://js.hcaptcha.com/1/api.js", apiUrl: "https://api.hcaptcha.com/siteverify", + requestContentType: "application/x-www-form-urlencoded", interactive: true, supportsScore: true, score: { min: 0.0, max: 0.5 }, @@ -100,6 +129,7 @@ export const CAPTCHA_PROVIDERS = { }), jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", apiUrl: "https://challenges.cloudflare.com/turnstile/v0/siteverify", + requestContentType: "application/x-www-form-urlencoded", interactive: true, supportsScore: false, formatAPISource: () => @@ -108,6 +138,26 @@ export const CAPTCHA_PROVIDERS = { }), keyURL: "https://dash.cloudflare.com", }, + cap: { + formatDisplayName: () => + msg("Cap", { + id: "captcha.providers.cap", + }), + formatDescription: () => + msg("Cap is a self-hostable CAPTCHA server that uses proof-of-work challenges.", { + id: "captcha.providers.cap.description", + }), + jsUrl: "https://cdn.jsdelivr.net/npm/cap-widget", + apiUrl: "https://cap.example.com/site-key/siteverify", + requestContentType: "application/json", + interactive: true, + supportsScore: false, + formatAPISource: () => + msg("Cap documentation", { + id: "captcha.providers.cap.setup-guide", + }), + keyURL: "https://trycap.dev/guide/", + }, custom: { formatDisplayName: () => msg("Custom", { @@ -115,12 +165,28 @@ export const CAPTCHA_PROVIDERS = { }), jsUrl: "https://www.recaptcha.net/recaptcha/api.js", apiUrl: "https://www.recaptcha.net/recaptcha/api/siteverify", + requestContentType: "application/x-www-form-urlencoded", interactive: false, supportsScore: true, score: { min: 0.5, max: 1.0 }, }, } as const satisfies Record; +export function deriveCapSiteVerifyURL(endpoint: string): string | null { + const trimmedEndpoint = endpoint.trim(); + + if (!URL.canParse(trimmedEndpoint)) { + return null; + } + + const endpointURL = new URL(trimmedEndpoint); + const normalizedEndpoint = endpointURL.href.endsWith("/") + ? endpointURL.href + : `${endpointURL.href}/`; + + return new URL("siteverify", normalizedEndpoint).toString(); +} + /** * Detect which provider preset matches the given {@linkcode CaptchaStage} instance. * This allows the form to show the correct provider in the dropdown when editing @@ -132,6 +198,14 @@ export function detectProviderFromInstance(stage?: CaptchaStage | null): Captcha for (const key of CaptchaProviderKeys) { const preset = CAPTCHA_PROVIDERS[key]; + if ( + key === "cap" && + stage.jsUrl?.includes("cap-widget") && + stage.requestContentType === preset.requestContentType + ) { + return key; + } + if (stage.jsUrl === preset.jsUrl && stage.apiUrl === preset.apiUrl) { return key; } @@ -153,6 +227,7 @@ export function pluckFormValues( return { jsUrl: instance.jsUrl, apiUrl: instance.apiUrl, + requestContentType: instance.requestContentType, interactive: instance.interactive, scoreMinThreshold: instance.scoreMinThreshold, scoreMaxThreshold: instance.scoreMaxThreshold, @@ -163,6 +238,7 @@ export function pluckFormValues( return { jsUrl: preset.jsUrl, apiUrl: preset.apiUrl, + requestContentType: preset.requestContentType, interactive: preset.interactive, scoreMinThreshold: preset.score?.min ?? 0.5, scoreMaxThreshold: preset.score?.max ?? 1.0, diff --git a/web/src/flow/stages/captcha/CaptchaStage.css b/web/src/flow/stages/captcha/CaptchaStage.css index 1256ec09e157..4a70ac0c7ecf 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.css +++ b/web/src/flow/stages/captcha/CaptchaStage.css @@ -36,4 +36,9 @@ ak-stage-captcha[theme="dark"].style-scope { background-color: var(--captcha-background-from); animation: captcha-background-animation 1s infinite var(--pf-global--TimingFunction); } + + &[data-transparent-loading="true"][data-ready="loading"] { + background-color: transparent; + animation: none; + } } diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index 1806123cbd7b..02635388354e 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -12,6 +12,7 @@ import { AKFormErrors, ErrorProp } from "#components/ak-field-errors"; import { FlowUserDetails } from "#flow/FormStatic"; import { BaseStage } from "#flow/stages/base"; import Styles from "#flow/stages/captcha/CaptchaStage.css"; +import { CapController } from "#flow/stages/captcha/controllers/cap"; import { CaptchaController, CaptchaControllerConstructor, @@ -53,7 +54,14 @@ interface LoadMessage { message: "load"; } -type IframeMessageEvent = MessageEvent; +interface ErrorMessage { + source?: string; + context?: string; + message: "error"; + error: string; +} + +type IframeMessageEvent = MessageEvent; @customElement("ak-stage-captcha") export class CaptchaStage @@ -79,6 +87,7 @@ export class CaptchaStage HCaptchaController, GReCaptchaController, TurnstileController, + CapController, ]); #logger = ConsoleLogger.prefix("flow:captcha"); @@ -165,6 +174,9 @@ export class CaptchaStage return match(data) .with({ message: "captcha" }, ({ token }) => this.onTokenChange(token)) .with({ message: "load" }, this.#loadListener) + .with({ message: "error" }, ({ error }) => { + this.error = error; + }) .otherwise(({ message }) => { this.#logger.debug(`Unknown message: ${message}`); }); @@ -183,12 +195,17 @@ export class CaptchaStage } if (this.challenge?.interactive) { + // Cap renders its own framed widget, so the generic iframe loading shimmer looks like + // an extra CAPTCHA box flashing behind it. + const isCapChallenge = this.challenge.jsUrl.includes("cap-widget"); + return html` @@ -306,8 +323,13 @@ export class CaptchaStage // Then, load the new script... const scriptElement = document.createElement("script"); + const matchedController = Array.from(CaptchaStage.controllers).find((Controller) => + Controller.matchesURL(challengeURL), + ); scriptElement.src = challengeURL.toString(); + scriptElement.type = + matchedController?.scriptType === "module" ? "module" : "text/javascript"; scriptElement.async = true; scriptElement.defer = true; scriptElement.onload = this.#scriptLoadListener; @@ -530,6 +552,7 @@ export class CaptchaStage challengeURL: challengeURL.toString(), theme: this.activeTheme, scriptOnLoad: !(controller instanceof TurnstileController), + scriptType: controller.scriptType, }); if ( diff --git a/web/src/flow/stages/captcha/controllers/CaptchaController.ts b/web/src/flow/stages/captcha/controllers/CaptchaController.ts index 6da5320f84eb..6c0c2820d642 100644 --- a/web/src/flow/stages/captcha/controllers/CaptchaController.ts +++ b/web/src/flow/stages/captcha/controllers/CaptchaController.ts @@ -28,6 +28,20 @@ export abstract class CaptchaController implements ReactiveController { return (this.constructor as typeof CaptchaController).globalName; } + public static readonly scriptType: "classic" | "module" = "classic"; + + public get scriptType(): "classic" | "module" { + return (this.constructor as typeof CaptchaController).scriptType; + } + + public static isAvailable(): boolean { + return Object.hasOwn(window, this.globalName); + } + + public static matchesURL(_url: URL): boolean { + return false; + } + /** * A prefix for log messages from this controller. */ @@ -42,7 +56,7 @@ export abstract class CaptchaController implements ReactiveController { ): Array { return Array.from(controllerConstructors).filter((Controller) => { // Can we find the global for this captcha provider? - return Object.hasOwn(window, Controller.globalName); + return Controller.isAvailable(); }); } @@ -98,6 +112,9 @@ export abstract class CaptchaController implements ReactiveController { export type CaptchaControllerConstructor = { globalName: string; + scriptType: "classic" | "module"; + isAvailable: () => boolean; + matchesURL: (url: URL) => boolean; } & (new (host: CaptchaHandlerHost) => CaptchaController); export interface CaptchaHandlerHost extends ReactiveControllerHost { diff --git a/web/src/flow/stages/captcha/controllers/cap.ts b/web/src/flow/stages/captcha/controllers/cap.ts new file mode 100644 index 000000000000..0fd00375b76a --- /dev/null +++ b/web/src/flow/stages/captcha/controllers/cap.ts @@ -0,0 +1,57 @@ +import { CaptchaController } from "#flow/stages/captcha/controllers/CaptchaController"; + +import { html } from "lit"; + +export class CapController extends CaptchaController { + public static readonly globalName = "cap-widget"; + + public static readonly scriptType = "module"; + + public static override isAvailable(): boolean { + return customElements.get("cap-widget") !== undefined; + } + + public static override matchesURL(url: URL): boolean { + return url.pathname.includes("cap-widget"); + } + + public interactive = () => { + const endpoint = this.host.challenge?.siteKey ?? ""; + + return html`
+ +
+ `; + }; + + public refreshInteractive = async () => { + this.host.iframeRef.value?.contentWindow?.location.reload(); + }; + + public execute = async () => { + throw new Error("Cap requires interactive mode."); + }; + + public refresh = async () => { + throw new Error("Cap requires interactive mode."); + }; +} diff --git a/web/src/flow/stages/captcha/shared.ts b/web/src/flow/stages/captcha/shared.ts index 5245809e7f72..a3613aa4219a 100644 --- a/web/src/flow/stages/captcha/shared.ts +++ b/web/src/flow/stages/captcha/shared.ts @@ -30,6 +30,7 @@ export interface IFrameTemplateInit { * Defaults to `true`. */ scriptOnLoad?: boolean; + scriptType?: "classic" | "module"; } /** @@ -42,7 +43,7 @@ export interface IFrameTemplateInit { */ export function iframeTemplate( children: TemplateResult, - { challengeURL, theme, scriptOnLoad = true }: IFrameTemplateInit, + { challengeURL, theme, scriptOnLoad = true, scriptType = "classic" }: IFrameTemplateInit, ) { return createDocumentTemplate({ head: html` @@ -75,7 +76,7 @@ export function iframeTemplate( ${children} `, diff --git a/web/src/flow/stages/identification/controllers/CaptchaDisplayController.ts b/web/src/flow/stages/identification/controllers/CaptchaDisplayController.ts index b77691567d91..918db0473627 100644 --- a/web/src/flow/stages/identification/controllers/CaptchaDisplayController.ts +++ b/web/src/flow/stages/identification/controllers/CaptchaDisplayController.ts @@ -49,6 +49,12 @@ export class CaptchaDisplayController implements ReactiveController { const input = this.#inputRef.value; if (!input) return; input.value = token; + // The surrounding identification form only updates its validity when form controls + // emit normal input events, so mirror a user's field change after the CAPTCHA solves. + input.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + input.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + this.#loaded = true; + this.host.requestUpdate(); }; public onFailure() { diff --git a/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md b/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md index 3f65660a9c56..70da8c24f27e 100644 --- a/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md +++ b/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md @@ -2,7 +2,7 @@ title: Captcha stage --- -The Captcha stage adds CAPTCHA verification to a flow by using Google reCAPTCHA or compatible alternatives like hCaptcha and Cloudflare Turnstile. +The Captcha stage adds CAPTCHA verification to a flow by using Google reCAPTCHA or compatible alternatives like hCaptcha, Cloudflare Turnstile, and Cap. ## Overview @@ -20,6 +20,7 @@ It can either be bound to a flow or embedded inside the [Identification stage](. - **Error on invalid score**: show an error immediately when the score is outside the configured threshold. If disabled, the flow continues and policies can inspect the result from context. - **JS URL**: JavaScript loader URL for the provider. - **API URL**: verification endpoint URL for the provider. +- **Request content type**: content type used when authentik verifies the CAPTCHA token with the provider. ## Flow integration @@ -55,6 +56,23 @@ Recommended values: Score thresholds only apply to hCaptcha Enterprise. +### Cap + +Cap is a self-hostable CAPTCHA server that uses proof-of-work challenges. + +See https://trycap.dev/guide/. + +Recommended values: + +- **Public key**: public Cap endpoint for the site key path, for example `https://cap.example.com/site-key/` +- **Private key**: Cap secret key +- **Interactive**: enabled +- **JS URL**: `https://cdn.jsdelivr.net/npm/cap-widget` +- **API URL**: Cap verification endpoint, for example `https://cap.example.com/site-key/siteverify` +- **Request content type**: JSON + +Cap does not use score thresholds. + ### Cloudflare Turnstile See https://developers.cloudflare.com/turnstile/get-started/migrating-from-recaptcha.