release: WRNexusJS 0.5.0
This commit is contained in:
@@ -211,6 +211,38 @@ export async function POST(ctx) {
|
||||
|
||||
The CAPTCHA runtime binds its required-form check in the capture phase, so a `data-schema` validator cannot submit the form before CAPTCHA verification. After a successful form request, the component automatically creates a fresh challenge.
|
||||
|
||||
### Retryable operations such as login
|
||||
|
||||
A login may consume a valid CAPTCHA and then fail because the password is
|
||||
incorrect. Configure a short action-bound session grant so the user can correct
|
||||
their credentials without solving CAPTCHA again:
|
||||
|
||||
```ts
|
||||
const guard = captchaGuard({
|
||||
action: "auth-login",
|
||||
engine,
|
||||
bindHostname: true,
|
||||
bindSession: true,
|
||||
verifiedForMs: 5 * 60_000,
|
||||
});
|
||||
```
|
||||
|
||||
Keep the verified widget state for non-CAPTCHA form errors:
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
action="auth-login"
|
||||
required="true"
|
||||
resetOnError="false"
|
||||
/>
|
||||
```
|
||||
|
||||
The grant is stored in the current session and bound to the configured action.
|
||||
Expired grants and CAPTCHA-specific errors still require and load a fresh
|
||||
challenge. Keep login rate limits and authentication lockout enabled;
|
||||
`verifiedForMs` removes repeated human verification, not credential-abuse
|
||||
controls.
|
||||
|
||||
## Validate a schema and CAPTCHA together
|
||||
|
||||
```ts
|
||||
|
||||
@@ -8,6 +8,7 @@ CAPTCHA is an anti-automation signal, not authentication or authorization. Conti
|
||||
- Challenge IDs and response tokens use cryptographically secure random bytes.
|
||||
- Challenges expire, have attempt limits, and are consumed after a correct answer.
|
||||
- Response tokens are opaque, hashed at rest, action-bound, and single-use by default.
|
||||
- Optional `captchaGuard({ verifiedForMs })` grants are session-bound, action-bound, and short-lived; use them only for retryable operations and retain independent rate limits.
|
||||
- Optional hostname, session, and IP bindings are validated with constant-time comparisons.
|
||||
- Audio URLs contain a random access key and are private/no-store.
|
||||
- Public challenge payloads never include the answer. Renderer names and disturbance metadata are safe to expose because they do not reveal the answer.
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
showStatus: bool(data.captchaShowStatus, true),
|
||||
disabled: bool(data.captchaDisabled, false),
|
||||
required: bool(data.captchaRequired, true),
|
||||
resetOnError: bool(data.captchaResetOnError, true),
|
||||
compact: compact,
|
||||
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
|
||||
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
|
||||
@@ -905,6 +906,7 @@
|
||||
form: root.closest("form"),
|
||||
formSubmitListener: null,
|
||||
formSuccessListener: null,
|
||||
formErrorListener: null,
|
||||
};
|
||||
states.set(root, state);
|
||||
root.dataset.captchaSize = state.config.size;
|
||||
@@ -967,8 +969,25 @@
|
||||
state.formSuccessListener = function () {
|
||||
createChallenge(state, state.config.presentation);
|
||||
};
|
||||
state.formErrorListener = function (event) {
|
||||
// Verified response tokens are single-use. The receiving API may
|
||||
// consume one before another form field fails, so retry with a fresh
|
||||
// challenge instead of reusing an already-consumed token.
|
||||
var detail = event && event.detail ? event.detail : {};
|
||||
var captchaFailed =
|
||||
detail.code === "missing-input" ||
|
||||
detail.code === "invalid-input" ||
|
||||
detail.code === "expired" ||
|
||||
detail.code === "already-used" ||
|
||||
detail.code === "action-mismatch" ||
|
||||
detail.code === "hostname-mismatch" ||
|
||||
detail.code === "session-mismatch";
|
||||
if (state.config.resetOnError || captchaFailed)
|
||||
createChallenge(state, state.config.presentation);
|
||||
};
|
||||
state.form.addEventListener("submit", state.formSubmitListener, true);
|
||||
state.form.addEventListener("wire:success", state.formSuccessListener);
|
||||
state.form.addEventListener("wire:error", state.formErrorListener);
|
||||
}
|
||||
|
||||
updateNotRobotState(state);
|
||||
@@ -1002,6 +1021,9 @@
|
||||
if (state.form && state.formSuccessListener) {
|
||||
state.form.removeEventListener("wire:success", state.formSuccessListener);
|
||||
}
|
||||
if (state.form && state.formErrorListener) {
|
||||
state.form.removeEventListener("wire:error", state.formErrorListener);
|
||||
}
|
||||
try {
|
||||
if (state.externalApi && state.externalWidgetId != null) {
|
||||
if (typeof state.externalApi.remove === "function")
|
||||
|
||||
@@ -39,6 +39,7 @@ component Captcha {
|
||||
showStatus = true
|
||||
disabled = false
|
||||
required = true
|
||||
resetOnError = true
|
||||
compact = false
|
||||
|
||||
@event ready = function
|
||||
@@ -87,6 +88,7 @@ component Captcha {
|
||||
data-captcha-show-status='{showStatus}'
|
||||
data-captcha-disabled='{disabled}'
|
||||
data-captcha-required='{required}'
|
||||
data-captcha-reset-on-error='{resetOnError}'
|
||||
data-captcha-compact='{compact}'
|
||||
data-captcha-required-message='{requiredMessage}'
|
||||
data-captcha-incorrect-message='{incorrectMessage}'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -73,11 +73,44 @@ function defaultFailure(options: CaptchaGuardOptions, result: CaptchaVerificatio
|
||||
}
|
||||
|
||||
export function captchaGuard(options: CaptchaGuardOptions) {
|
||||
const verifiedForMs = options.verifiedForMs ?? 0;
|
||||
if (!Number.isFinite(verifiedForMs) || verifiedForMs < 0) {
|
||||
throw new TypeError("captchaGuard verifiedForMs must be a non-negative finite duration");
|
||||
}
|
||||
const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants";
|
||||
|
||||
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
|
||||
const action = resolveAction(options.action, ctx);
|
||||
const now = Date.now();
|
||||
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
|
||||
const grant = verifiedForMs > 0 ? validCaptchaGrant(grants, action, now) : undefined;
|
||||
if (grant) {
|
||||
ctx.locals.captcha = {
|
||||
success: true,
|
||||
provider: grant.provider,
|
||||
action,
|
||||
};
|
||||
ctx.locals.captchaVerified = true;
|
||||
return next();
|
||||
}
|
||||
|
||||
const result = await verifyRequest(ctx, options);
|
||||
ctx.locals.captcha = result;
|
||||
if (!result.success)
|
||||
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
|
||||
if (verifiedForMs > 0) {
|
||||
const fresh: CaptchaSessionGrant = {
|
||||
action,
|
||||
provider: result.provider,
|
||||
expiresAt: now + verifiedForMs,
|
||||
};
|
||||
ctx.session.set(sessionKey, [
|
||||
...grants.filter((item) => item.expiresAt > now && item.action !== action),
|
||||
fresh,
|
||||
]);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +313,15 @@ export interface CaptchaGuardOptions {
|
||||
responseField?: string;
|
||||
provider?: CaptchaProvider;
|
||||
engine?: CaptchaEngine;
|
||||
/**
|
||||
* Keep successful verification bound to the current session and action for
|
||||
* this duration. Useful for retryable operations such as login, where a
|
||||
* valid CAPTCHA should not be repeated after an unrelated credential error.
|
||||
* Defaults to 0 (every request needs its own response token).
|
||||
*/
|
||||
verifiedForMs?: number;
|
||||
/** Session key used for verified grants. */
|
||||
sessionKey?: string;
|
||||
failureStatus?: number;
|
||||
failureMessage?: string;
|
||||
bindHostname?: boolean;
|
||||
@@ -325,7 +334,6 @@ export interface CaptchaPageGateOptions extends CaptchaGuardOptions {
|
||||
policy?: CaptchaPolicyOptions;
|
||||
challengePath?: string;
|
||||
returnToParam?: string;
|
||||
sessionKey?: string;
|
||||
signals?: (ctx: Context) => CaptchaRiskSignals | Promise<CaptchaRiskSignals>;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,14 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
|
||||
expect(source).toContain("captchaRandomizeStyle");
|
||||
expect(source).toContain("captchaResolvedImageStyle");
|
||||
expect(source).toContain("captchaShowListen");
|
||||
expect(source).toContain("captchaResetOnError");
|
||||
expect(source).toContain("normalizeSize");
|
||||
expect(source).toContain("verifyNotRobot");
|
||||
expect(source).toContain("stopImmediatePropagation");
|
||||
expect(source).toContain('addEventListener("submit"');
|
||||
expect(source).toContain('addEventListener("wire:error"');
|
||||
expect(source).toContain("state.config.resetOnError || captchaFailed");
|
||||
expect(source).toContain("Verified response tokens are single-use");
|
||||
expect(source).toContain("MutationObserver");
|
||||
expect(source).toContain("new CustomEvent(name");
|
||||
});
|
||||
@@ -42,6 +46,7 @@ test("Captcha component delegates native browser work to the packaged runtime",
|
||||
expect(source).toContain("data-captcha-randomize-style");
|
||||
expect(source).toContain("data-captcha-show-listen");
|
||||
expect(source).toContain("data-captcha-not-robot-button");
|
||||
expect(source).toContain("data-captcha-reset-on-error");
|
||||
expect(source).not.toContain("lifecycle {");
|
||||
expect(source).not.toContain("async function");
|
||||
expect(source).not.toContain("await ");
|
||||
|
||||
@@ -26,6 +26,7 @@ test("Captcha.wrn parses and exposes the full public contract", async () => {
|
||||
"color",
|
||||
"class",
|
||||
"showListen",
|
||||
"resetOnError",
|
||||
]) {
|
||||
expect(source).toContain(`${prop} =`);
|
||||
}
|
||||
@@ -50,6 +51,7 @@ test("Captcha.wrn parses and exposes the full public contract", async () => {
|
||||
expect(source).toContain("data-captcha-excluded-styles='{excludedStyles}'");
|
||||
expect(source).toContain("data-captcha-randomize-style='{randomizeStyle}'");
|
||||
expect(source).toContain("data-captcha-show-listen='{showListen}'");
|
||||
expect(source).toContain("data-captcha-reset-on-error='{resetOnError}'");
|
||||
expect(source).toContain("data-captcha-not-robot-button");
|
||||
expect(source).toContain("data-[captcha-size=compact]");
|
||||
expect(source).toContain("data-[captcha-size=compact]:max-w-xs");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createContext, type Context } from "@wrnexus/core";
|
||||
import { captchaPageGate } from "../src/middleware.ts";
|
||||
import { captchaGuard, captchaPageGate } from "../src/middleware.ts";
|
||||
import type { CaptchaEngine } from "../src/types.ts";
|
||||
|
||||
function fakeEngine(): CaptchaEngine {
|
||||
@@ -54,6 +54,61 @@ function sessionFixture() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("CAPTCHA request guard", () => {
|
||||
test("reuses an action-bound verified session grant for retryable requests", async () => {
|
||||
const session = sessionFixture();
|
||||
const guard = captchaGuard({
|
||||
action: "auth-login",
|
||||
engine: fakeEngine(),
|
||||
verifiedForMs: 5 * 60_000,
|
||||
});
|
||||
const firstRequest = new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ "wrn-captcha-response": "verified-token" }),
|
||||
});
|
||||
const firstContext: Context = {
|
||||
...createContext(firstRequest, new URL(firstRequest.url)),
|
||||
session,
|
||||
ip: "127.0.0.1",
|
||||
};
|
||||
const first = await guard(
|
||||
firstContext,
|
||||
() => new Response("credentials-invalid", { status: 401 }),
|
||||
);
|
||||
expect(first.status).toBe(401);
|
||||
|
||||
const retryRequest = new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const retryContext: Context = {
|
||||
...createContext(retryRequest, new URL(retryRequest.url)),
|
||||
session,
|
||||
ip: "127.0.0.1",
|
||||
};
|
||||
const retry = await guard(retryContext, () => new Response("retry-allowed"));
|
||||
|
||||
expect(await retry.text()).toBe("retry-allowed");
|
||||
expect(retryContext.locals.captcha).toMatchObject({
|
||||
success: true,
|
||||
action: "auth-login",
|
||||
});
|
||||
expect(retryContext.locals.captchaVerified).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid verified-session durations", () => {
|
||||
expect(() =>
|
||||
captchaGuard({
|
||||
action: "auth-login",
|
||||
engine: fakeEngine(),
|
||||
verifiedForMs: Number.NaN,
|
||||
}),
|
||||
).toThrow("verifiedForMs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CAPTCHA page gate", () => {
|
||||
test("accepts a verified form token and grants the protected route", async () => {
|
||||
const session = sessionFixture();
|
||||
|
||||
Reference in New Issue
Block a user