diff --git a/.agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md b/.agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md new file mode 100644 index 00000000..949c3334 --- /dev/null +++ b/.agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md @@ -0,0 +1,108 @@ +# Client-side puzzle completion is not a server-verifiable authentication proof + +Date: 2026-10-27. Searched the symptom/security indexes and the October 23 +security inventory before addressing #183. The existing helper accepted any +six uppercase alphanumeric characters; login/signup also skipped the helper +when a payload was omitted. Correct credentials remained necessary for password +login: this was an abuse-control bypass, not proof of account takeover. + +## Reproduction and correction + +With Node 22.18.0 / npm 10.8.2 in an owned worktree based on +`ef495eff15ed7aac51e72dc7a1db1bb96d2b3695`: + +```sh +npm run test -w @graphdone/server -- tests/captcha.test.ts --maxWorkers=1 --minWorkers=1 +# Before: arbitrary code ABC123 was accepted; the new rejection assertion failed. +``` + +The corrected policy requires proof for production login/signup and for Core +magic-link, forgot-password and reset-password requests in every mode. Missing +proof is allowed only for login/signup in explicit development/test mode to +retain development quick login. Supplied proofs are always verified. Guest +access retains its existing separate policy; OAuth callbacks and email-code +verification are not reclassified as these challenge actions. + +The existing installed `altcha-lib@1.4.1` / `altcha@2.3.0` protocol is retained; +no dependency upgrade or provider account is added. The server issues signed +SHA-256 challenges with a five-minute expiry and action. A bounded ledger +authenticates the exact issued salt/signature/action, validates the solution, +rechecks expiry after asynchronous verification and atomically consumes it. +At most 5,000 outstanding/pending challenges are admitted; concurrent issuance +reserves capacity before awaiting cryptography. Live proofs are not evicted to +admit more work. Challenge issuance is limited to 30/minute per observed socket +IP (IPv6 normalized); forwarded headers do not redefine Core's trust policy. + +An unset key generates a random process-local signing key. Explicit keys must +be at least 32 characters; the old public fallback is rejected, including at +production startup. Outstanding challenges intentionally fail after restart. + +## Evidence + +- Original failure retained in `captcha-before.log` under the owned temporary + resolution evidence directory. +- All 139 server tests passed, including nine helper/policy negatives, three + real HTTP GraphQL/SQLite tests, existing password/IP/alias-concurrency tests, + and production startup's fallback-key rejection. +- Three UI component tests cover proof invalidation, action/reset, failed policy + loading and accessible retry. Policy fetch failure never becomes success. +- Four headed browser cases passed through the built frontend served by nginx + and a production-mode API: direct REST/GraphQL omission/forgery rejection; + 390px/1440px real worker-solved proof, failed password then new-proof success; + and policy-load failure with keyboard retry. No email was delivered. +- Clean install, types and build passed; lint passed before the final explicit + socket-IP key generator, whose final composed checks remain to be recorded. +- Earlier browser failures are retained: the test initially forgot to choose + the Password tab; `.check()` incorrectly expected a PoW checkbox to become + checked before asynchronous solving; reusing the same API process exhausted + its existing strict rate limit. The corrected test clicks, waits for verified + status and asserts checked state, against a fresh owned API process. Limits + were not weakened. Initial Vite invocation from the wrong cwd omitted styles; + final evidence uses the built artifact through nginx. + +The browser uses the self-host nginx configuration with only its listener and +API upstream replaced for isolated loopback ports (28548 / 28537). Neo4j/APOC +and SQLite fixtures are owned; the user's localhost application is untouched. +Local traces can include synthetic fixture credentials and remain private. + +## Corrective rule and limits + +Enforce challenge policy at each server action, including direct GraphQL aliases +and the auth-only schema. Client disabling/hiding controls is not enforcement. +Clear consumed/expired proofs and provide a keyboard-accessible retry. Preserve +backend contracts explicitly: Core advertises ALTCHA through `/config`; Cloud's +existing contract remains separate and is not proven by these tests. + +This is a bounded proof-of-work abuse control, not proof that a visitor is human. +The ledger/rate limits are process-local; multiple API instances require a +shared or sticky challenge contract before availability parity can be claimed. +Unresponsive, restarted or over-capacity verification fails closed. Email token +purpose, OAuth linking and reset-session revocation remain #184. Hosted parity, +distributed limits, release/deployment and actual email delivery remain separate +work. Independent exact-head review and final CI are still required. + +## Review corrections + +Independent review of `b12ef2c1f0064ac7cc838ac1e806c52134a6802f` found that +`req.ip` can incorporate caller-supplied forwarding headers with the shipped +`TRUST_PROXY=2` configuration. The challenge limiter now reads only +`req.socket.remoteAddress`; an actual HTTP negative rotates 31 forwarded IPs +under Express trust proxy 2 and confirms that the 31st request is denied. + +Review also found that an enabled password button could spend login attempts +while proof was missing/expired or policy loading failed. The UI now honors +advertised `requiredActions`, disables ordinary submission until ready, and +guards the submit handler too. Tests assert zero Login mutations in those +states, preserving explicitly optional development/legacy actions. A +non-navigating unverified-email result also clears consumed proof. Reset/recovery +forms clear their payload before releasing their loading state. + +The focus regression exposed that ALTCHA's autofocus concerns completion, +not initial mode entry. Initial checkbox focus is now explicit on desktop mode +entry and user-requested retry; typing an email does not transfer focus. The +existing empty-email test now actually leaves and re-enters passwordless mode +instead of clicking the already-selected tab. Both focus tests passed after +that correction. Earlier test implementation errors (Fetch `status` property +versus Playwright's `status()` method and an unsupported Testing Library +`exact` option) are retained in local logs and were corrected before accepting +the results. Final composed evidence belongs in the PR discussion. diff --git a/.agents/inbox/2026-10-27-dev-launch-must-declare-mode.md b/.agents/inbox/2026-10-27-dev-launch-must-declare-mode.md new file mode 100644 index 00000000..9b06a071 --- /dev/null +++ b/.agents/inbox/2026-10-27-dev-launch-must-declare-mode.md @@ -0,0 +1,46 @@ +# Development launch must declare its mode before authentication policy is read + +On 2026-10-27, [PR #195 CI run40573347298](https://github.com/GraphDone/GraphDone-Core/actions/runs/40573347298) +failed every basic smoke case at authentication on source +`8c9712b7bda0c7515568c08bae710be4bea675a1` (merge composition +`2b16ba2e2318e765883c1fd5ef312a0445addec2`). The sequence reported0 passes, +6 failed cases plus its failed-process evidence row. Trace inspection, with +credentials/tokens omitted, showed `/config` requiring all five CAPTCHA actions, +GraphQL `Please complete verification and try again`, then the existing +password-attempt limiter returning429 after repeated quick-login attempts. +The dedicated production proof/browser tests had passed and did not cover this +development launch contract. + +Cause: CI invokes `npm run dev` without a `.env` or explicit `NODE_ENV`. +The API's old dev command launched `src/index.ts` directly, leaving mode unset. +CAPTCHA deliberately requires proof in unknown modes, while developmentInfo +advertised default-account shortcuts in every non-production mode. Changing +that policy to treat unknown mode as optional would mask the mismatch. + +Correction: the explicit dev command launches `src/dev.ts`. It first loads and +validates environment configuration, defaults only an absent mode to +`development`, then dynamically imports the API. Explicit production, unknown +and blank modes are preserved. Development shortcuts are advertised only for +explicit development/test modes. Production `start` still imports the normal +API directly, and production/unknown CAPTCHA remains required. + +Validation on Node22.18.0: six entry-point controls plus nine production startup +and nine CAPTCHA controls passed (24/24); server typecheck/build passed. An +owned invocation of the actual npm dev command with `NODE_ENV` unset, an empty +owned server data directory, disposable Neo4j and separate loopback ports +reported only magic-link/forgot-password/reset-password as required actions. +The existing browser smoke suite passed6/6 headed, one worker, zero retries, +2.3 minutes. This exercised actual quick login rather than a stubbed frontend. + +The first entry test incorrectly used Vitest1's `stubEnv(undefined)`, which +created the literal string `undefined`; its cached mocked API factory also did +not rerun after `resetModules`. Correct fixtures delete the variable explicitly +and reset the mock factory. Those six fixture failures are retained separately +from the real CI failure. Assertions, challenge difficulty, login budgets and +CI thresholds were not relaxed. Final full server/harness and exact-head CI +results remain in the PR discussion; no deployment or hosted result is claimed. + +Corrective rule: make the launch command's mode explicit without overwriting +operator configuration, then test that command as well as isolated policy. +Keep unknown-mode policy fail closed and record failed mode/shortcut alignment +as an application regression, not merely a flaky test retry. diff --git a/.agents/inbox/2026-10-27-widget-events-need-commit-phase-listeners.md b/.agents/inbox/2026-10-27-widget-events-need-commit-phase-listeners.md new file mode 100644 index 00000000..ffe3447a --- /dev/null +++ b/.agents/inbox/2026-10-27-widget-events-need-commit-phase-listeners.md @@ -0,0 +1,52 @@ +# Attach verification listeners before a committed widget can emit + +Date: 2026-10-27. + +## Evidence + +Token head `017a509443ead9b58febc940b4b63ed4aab70a97`, CI +[40591509438](https://github.com/GraphDone/GraphDone-Core/actions/runs/40591509438), +failed the web CAPTCHA case: expected the latest verified callback to contain +`issued-proof`, received an empty string. The DOM-presence assertion had passed. +The log does not itself record React phase ordering. + +The deterministic regression wraps the actual component in `React.Profiler`. +Its commit callback dispatches one verification event for each newly observed +widget, after child layout effects and before passive effects. Against the old +component, `npm exec --workspace @graphdone/web -- vitest run src/components/AuthCaptcha.test.tsx` +failed the new case while four existing controls passed. Changing only the +listener/reset block to `useLayoutEffect` passed all five. An independent probe +reproduced the same before/after outcome. + +A first local unbounded full-suite run also retained an unrelated 5-second +WorkItemDetailsModal timeout and its subsequent save-control failure. With the +same action deadlines and bounded workers (`--maxWorkers=2 --minWorkers=1`), +the complete web suite passed 353/353. This is explicitly different execution +configuration, not a silent retry or proof of that timeout's precise cause. + +## Cause and corrective rule + +A committed custom element can be visible before a passive effect installs its +event listener. An immediately emitted proof can therefore be lost. Attach and +clean up the verification listener in the layout phase; policy fetching and +focus behavior remain passive. Preserve failure, expiry and replacement resets. +See [React layout effect timing](https://react.dev/reference/react/useLayoutEffect). + +Test the early event through actual React commit ordering. Retain controls that +replacement widgets deliver events and removed, prior-action and unmounted +widgets cannot deliver stale proofs. Do not fix this with an added test wait, +retry, or longer timeout. No authentication policy or proof validation changes. + +## Limits + +The regression proves this lifecycle hazard, not the exact CI scheduling trace. +It does not certify arbitrary custom elements that synchronously emit from a +constructor or connectedCallback before React attaches refs. Installed ALTCHA +initialization yields asynchronously; actual production-mode browser acceptance +and final exact-head CI remain separate gates. + +The corrected source passed build, types, lint and 179/179 harness checks. +The rebuilt SPA against its owned production-mode API passed all four +`qc:captcha` browser cases in 17.8 seconds with one worker and zero retries: +real proof/wrong-password/retry at 390px and 1440px, direct forged/absent REST +and GraphQL rejection, and pending/failed/expired submission blocking. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index f479c440..4dba7cc0 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,9 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| CAPTCHA widget is visible but an immediate verification event leaves proof empty | [Attach widget listeners during commit](../inbox/2026-10-27-widget-events-need-commit-phase-listeners.md); `AuthCaptcha.test.tsx` Profiler regression | +| CI development quick login fails required proof and exhausts login attempts with NODE_ENV unset | [Declare mode in the dev entry point without overriding production](../inbox/2026-10-27-dev-launch-must-declare-mode.md); `development-startup.test.ts` and actual dev smoke | +| Arbitrary client CAPTCHA strings succeed, or direct login/signup omits proof | [Require server-issued action-bound proof and atomic consumption](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); server `captcha` / `captcha-auth` tests and `qc:captcha` | | An old audit lists a dependency that the current advisory identifies as patched | [Reconcile advisory backports with the existing lock](../inbox/2026-10-27-reconcile-advisory-backports-with-locked-consumers.md); `selector-parser.test.mjs` | | Phone idle-motion sample includes one camera jump immediately after rotation while physics is already at rest | [Wait for edits and camera resize readiness](../inbox/2026-10-27-motion-sampling-needs-camera-readiness.md); controlled ResizeObserver delivery in `graph-motion.spec.ts` | | Animated zoom stops near the overview exit threshold instead of reaching its target | [Title refresh must not own the camera](../inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md); frame-controlled `semantic-lod.spec.ts` negative control | diff --git a/.agents/issues/security-audit-index.md b/.agents/issues/security-audit-index.md index 5397b2e1..b4489c7d 100644 --- a/.agents/issues/security-audit-index.md +++ b/.agents/issues/security-audit-index.md @@ -2,6 +2,7 @@ | Symptom | Evidence and corrective rule | | --- | --- | +| CAPTCHA validates only a client string's format or skips missing GraphQL proof | [Server policy, signed action/expiry and bounded replay](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); `packages/server/tests/captcha*.test.ts`, `qc:captcha` | | Historical CSS audit findings disappear while the lock is unchanged | [Reconcile official backport ranges with actual consumers](../inbox/2026-10-27-reconcile-advisory-backports-with-locked-consumers.md); locked-path and Tailwind/parser regressions | | Router advisory requires a major update and older upstream navigation expectations differ | [Installed consumer and route compatibility](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); every locked Router path plus desktop/phone route gates | | Fresh production audit flags Undici through optional SQLite build tooling without a direct application import | [Trace the native-build consumer and patch its actual resolved copy](../inbox/2026-10-24-undici-native-build-dependency.md); `tests/lib/dependencies/undici.test.mjs` rejects affected locked and installed consumer versions | diff --git a/package.json b/package.json index f9d3b6a0..aaccf83f 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,8 @@ "qc:project-transfer": "playwright test --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line tests/e2e/quality/project-transfer.spec.ts", "qc:operations": "playwright test tests/e2e/quality/operations.spec.ts --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line", "qc:operator-login": "playwright test tests/e2e/quality/operator-login.spec.ts --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line", - "test:operations": "tsx scripts/test-operations-local.mts && tsx scripts/test-admin-bootstrap.mts" + "test:operations": "tsx scripts/test-operations-local.mts && tsx scripts/test-admin-bootstrap.mts", + "qc:captcha": "playwright test --config playwright.captcha.config.ts --headed --workers=1 --retries=0" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/packages/server/package.json b/packages/server/package.json index abe58c66..d740cb65 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "dev": "tsx watch src/index.ts", + "dev": "tsx watch src/dev.ts", "start": "node dist/index.js", "test": "vitest --run", "test:coverage": "vitest run --coverage", diff --git a/packages/server/src/config/secrets.ts b/packages/server/src/config/secrets.ts index 1c1a9472..239c01e8 100644 --- a/packages/server/src/config/secrets.ts +++ b/packages/server/src/config/secrets.ts @@ -23,6 +23,11 @@ export function validateProductionSecrets(environment: Record(), async (_req, res) => { const config = { + captcha: { provider: 'altcha', requiredActions: CAPTCHA_ACTIONS.filter(captchaRequired) }, timestamp: new Date().toISOString(), services: { api: { @@ -724,7 +726,7 @@ async function startServer() { } // Verify CAPTCHA - const isCaptchaValid = await verifyCaptcha(captchaPayload); + const isCaptchaValid = await verifyCaptcha(captchaPayload, 'magic-link'); if (!isCaptchaValid) { return res.status(400).json({ error: 'CAPTCHA verification failed' }); } @@ -797,7 +799,7 @@ async function startServer() { } // Verify CAPTCHA - const isCaptchaValid = await verifyCaptcha(captchaPayload); + const isCaptchaValid = await verifyCaptcha(captchaPayload, 'forgot-password'); if (!isCaptchaValid) { return res.status(400).json({ error: 'CAPTCHA verification failed' }); } @@ -875,7 +877,7 @@ async function startServer() { } // Verify CAPTCHA - const isCaptchaValid = await verifyCaptcha(captchaPayload); + const isCaptchaValid = await verifyCaptcha(captchaPayload, 'reset-password'); if (!isCaptchaValid) { return res.status(400).json({ error: 'CAPTCHA verification failed' }); } @@ -970,13 +972,17 @@ async function startServer() { app.options('/api/captcha/challenge', cors(captchaCorsOptions)); - app.get('/api/captcha/challenge', cors(captchaCorsOptions), async (_req, res) => { + const captchaChallengeLimiter = createCaptchaChallengeLimiter(); + app.get('/api/captcha/challenge', cors(captchaCorsOptions), captchaChallengeLimiter, async (req, res) => { try { - const challenge = await createCaptchaChallenge(); - res.json(challenge); + if (!isCaptchaAction(req.query.action)) return res.status(400).json({ error: 'Invalid verification action' }); + const challenge = await createCaptchaChallenge(req.query.action); + res.setHeader('Cache-Control', 'no-store'); + return res.json(challenge); } catch (error) { - console.error('❌ CAPTCHA challenge creation failed:', error); // eslint-disable-line no-console - res.status(500).json({ error: 'Failed to create CAPTCHA challenge' }); + if (error instanceof CaptchaCapacityError) return res.status(503).json({ error: error.message }); + console.error('CAPTCHA challenge creation failed'); // eslint-disable-line no-console + return res.status(500).json({ error: 'Failed to create CAPTCHA challenge' }); } }); diff --git a/packages/server/src/resolvers/sqlite-auth.ts b/packages/server/src/resolvers/sqlite-auth.ts index b48af429..5da4fccd 100644 --- a/packages/server/src/resolvers/sqlite-auth.ts +++ b/packages/server/src/resolvers/sqlite-auth.ts @@ -1,7 +1,7 @@ import { GraphQLError } from 'graphql'; import { sqliteAuthStore } from '../auth/sqlite-auth.js'; import { generateToken } from '../utils/auth.js'; -import { verifyCaptcha } from '../utils/captcha.js'; +import { verifyAuthCaptcha } from '../utils/captcha.js'; import { randomUUID } from 'node:crypto'; import { folderActor, folderCreationOwner, folderResponse, requireFolderAccess, requireFolderDeletion, requireFolderParent, requireGraphAccess, visibleFolderGraphs } from '../auth/folder-access.js'; import { passwordLoginLimiter, PasswordLoginLimitError } from '../auth/password-login-limiter.js'; @@ -144,7 +144,7 @@ export const sqliteAuthResolvers = { // Get development mode info and default credentials developmentInfo: async () => { - const isDevelopment = process.env.NODE_ENV !== 'production'; + const isDevelopment = ['development', 'test'].includes(process.env.NODE_ENV || ''); return { isDevelopment, @@ -329,17 +329,11 @@ export const sqliteAuthResolvers = { const user = await sqliteAuthStore.findUserByEmailOrUsername(identifier); accountAttempt = passwordLoginLimiter.reserveAccount(user ? `user:${user.id}` : `identifier:${identifier}`); - // Verify CAPTCHA if provided - if (input.captchaPayload) { - const isCaptchaValid = await verifyCaptcha(input.captchaPayload); - if (!isCaptchaValid) { - outcome = 'failure'; - console.log('❌ CAPTCHA verification failed'); - throw new GraphQLError('CAPTCHA verification failed', { - extensions: { code: 'CAPTCHA_FAILED' } - }); - } - console.log('✅ CAPTCHA verified'); + if (!await verifyAuthCaptcha(input.captchaPayload, 'login')) { + outcome = 'failure'; + throw new GraphQLError('Please complete verification and try again.', { + extensions: { code: 'CAPTCHA_FAILED' } + }); } if (!user) { @@ -441,16 +435,10 @@ export const sqliteAuthResolvers = { }); } - // Verify CAPTCHA if provided - if (input.captchaPayload) { - const isCaptchaValid = await verifyCaptcha(input.captchaPayload); - if (!isCaptchaValid) { - console.log('❌ CAPTCHA verification failed'); - throw new GraphQLError('CAPTCHA verification failed', { - extensions: { code: 'CAPTCHA_FAILED' } - }); - } - console.log('✅ CAPTCHA verified'); + if (!await verifyAuthCaptcha(input.captchaPayload, 'signup')) { + throw new GraphQLError('Please complete verification and try again.', { + extensions: { code: 'CAPTCHA_FAILED' } + }); } // Check if user already exists diff --git a/packages/server/src/schema/auth-only-schema.ts b/packages/server/src/schema/auth-only-schema.ts index 38765b46..970877ef 100644 --- a/packages/server/src/schema/auth-only-schema.ts +++ b/packages/server/src/schema/auth-only-schema.ts @@ -52,6 +52,7 @@ export const authOnlyTypeDefs = gql` } input SignupInput { + captchaPayload: String email: String! username: String! password: String! @@ -60,6 +61,7 @@ export const authOnlyTypeDefs = gql` } input LoginInput { + captchaPayload: String emailOrUsername: String! password: String! } @@ -284,4 +286,4 @@ export const authOnlyTypeDefs = gql` graphId: String! position: Int! } -`; \ No newline at end of file +`; diff --git a/packages/server/src/utils/captcha-rate-limit.ts b/packages/server/src/utils/captcha-rate-limit.ts new file mode 100644 index 00000000..f0bd1c0e --- /dev/null +++ b/packages/server/src/utils/captcha-rate-limit.ts @@ -0,0 +1,9 @@ +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; + +export function createCaptchaChallengeLimiter() { + return rateLimit({ + windowMs: 60000, limit: 30, standardHeaders: 'draft-8', legacyHeaders: false, + // Socket-only even when the application enables Express trust proxy. + keyGenerator: req => ipKeyGenerator(req.socket.remoteAddress || 'unknown'), + }); +} diff --git a/packages/server/src/utils/captcha.ts b/packages/server/src/utils/captcha.ts index 3a3fdec8..16eba36a 100644 --- a/packages/server/src/utils/captcha.ts +++ b/packages/server/src/utils/captcha.ts @@ -1,41 +1,95 @@ -import { verifySolution, createChallenge } from 'altcha-lib'; +import { randomBytes } from 'node:crypto'; +import { createChallenge, verifySolution } from 'altcha-lib'; -const ALTCHA_HMAC_KEY = process.env.ALTCHA_HMAC_KEY || 'your-secret-hmac-key-change-in-production'; +export const CAPTCHA_ACTIONS = ['login', 'signup', 'magic-link', 'forgot-password', 'reset-password'] as const; +export type CaptchaAction = typeof CAPTCHA_ACTIONS[number]; +export function isCaptchaAction(value: unknown): value is CaptchaAction { + return typeof value === 'string' && CAPTCHA_ACTIONS.some(action => action === value); +} + +/** Development quick-login remains available; production policy never comes + * from a request or browser setting. Supplied proofs are checked in every mode. */ +export function captchaRequired(action: CaptchaAction): boolean { + return !['development', 'test'].includes(process.env.NODE_ENV || '') || !['login', 'signup'].includes(action); +} + +export class CaptchaCapacityError extends Error {} -export async function verifyCaptcha(payload: string | null | undefined): Promise { - if (!payload) { - return false; +export class CaptchaService { + private readonly issued = new Map(); + private pending = 0; + + constructor(private readonly options: { + key: string; + now?: () => number; + ttlMs?: number; + capacity?: number; + maxNumber?: number; + }) {} + + private now(): number { return this.options.now?.() ?? Date.now(); } + private prune(): void { + const now = this.now(); + for (const [id, entry] of this.issued) if (entry.expires <= now) this.issued.delete(id); } - try { - // Check if it's a simple code (6 alphanumeric characters) - // This is for the CodeCaptcha component - const simpleCodePattern = /^[A-Z0-9]{6}$/; - if (simpleCodePattern.test(payload)) { - console.log('✅ Code CAPTCHA verified:', payload); - return true; + async create(action: CaptchaAction) { + if (!isCaptchaAction(action)) throw new Error('Invalid CAPTCHA action'); + this.prune(); + if (this.issued.size + this.pending >= (this.options.capacity ?? 5000)) { + throw new CaptchaCapacityError('Verification is busy. Please try again shortly.'); } + this.pending += 1; + try { + const expires = this.now() + (this.options.ttlMs ?? 300000); + const challenge = await createChallenge({ + hmacKey: this.options.key, algorithm: 'SHA-256', + maxNumber: this.options.maxNumber ?? 100000, + saltLength: 16, expires: new Date(expires), params: { action }, + }); + this.issued.set(challenge.challenge, { action, expires, salt: challenge.salt, signature: challenge.signature }); + return challenge; + } finally { this.pending -= 1; } + } - // Otherwise, try to verify as Altcha payload - const isValid = await verifySolution(payload, ALTCHA_HMAC_KEY); - return isValid; - } catch (error) { - console.error('CAPTCHA verification error:', error); - return false; + async verify(payload: unknown, action: CaptchaAction): Promise { + if (!isCaptchaAction(action) || typeof payload !== 'string' || !payload || payload.length > 4096) return false; + this.prune(); + try { + const proof = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')); + if (!proof || proof.algorithm !== 'SHA-256' || !Number.isSafeInteger(proof.number) || proof.number < 0 || + proof.number > (this.options.maxNumber ?? 100000) || typeof proof.challenge !== 'string') return false; + const entry = this.issued.get(proof.challenge); + if (!entry || entry.action !== action || entry.salt !== proof.salt || entry.signature !== proof.signature) return false; + if (!await verifySolution(proof, this.options.key, false)) return false; + if (entry.expires <= this.now() || this.issued.get(proof.challenge) !== entry) return false; + return this.issued.delete(proof.challenge); + } catch { return false; } } } -export async function createCaptchaChallenge() { - try { - const challenge = await createChallenge({ - hmacKey: ALTCHA_HMAC_KEY, - maxNumber: 100000, - saltLength: 12, - algorithm: 'SHA-256', - }); - return challenge; - } catch (error) { - console.error('CAPTCHA challenge creation error:', error); - throw new Error('Failed to create CAPTCHA challenge'); +let service: CaptchaService | undefined; +function getService(): CaptchaService { + if (!service) { + const configured = process.env.ALTCHA_HMAC_KEY?.trim(); + if (configured !== undefined && (configured.length < 32 || configured === 'your-secret-hmac-key-change-in-production')) { + throw new Error('ALTCHA_HMAC_KEY must be a random secret of at least 32 characters, or unset for a process-local key.'); + } + // Outstanding proofs are invalid after restart, matching the bounded ledger. + service = new CaptchaService({ key: configured ?? randomBytes(32).toString('hex') }); } + return service; +} + +export async function verifyCaptcha(payload: unknown, action: CaptchaAction): Promise { + return getService().verify(payload, action); +} + +export async function verifyAuthCaptcha(payload: unknown, action: CaptchaAction): Promise { + if ((payload === undefined || payload === null || payload === '') && !captchaRequired(action)) return true; + return verifyCaptcha(payload, action); +} + +export async function createCaptchaChallenge(action: CaptchaAction) { + return getService().create(action); } diff --git a/packages/server/tests/captcha-auth.test.ts b/packages/server/tests/captcha-auth.test.ts new file mode 100644 index 00000000..7775f8d7 --- /dev/null +++ b/packages/server/tests/captcha-auth.test.ts @@ -0,0 +1,114 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import sqlite3 from 'sqlite3'; +import express from 'express'; +import { createServer, type Server } from 'node:http'; +import { ApolloServer } from '@apollo/server'; +import { expressMiddleware } from '@as-integrations/express4'; +import { solveChallenge } from 'altcha-lib'; +import { sqliteAuthStore } from '../src/auth/sqlite-auth'; +import { sqliteAuthResolvers } from '../src/resolvers/sqlite-auth'; +import { authOnlyTypeDefs } from '../src/schema/auth-only-schema'; +import { createCaptchaChallenge, type CaptchaAction } from '../src/utils/captcha'; +import { createCaptchaChallengeLimiter } from '../src/utils/captcha-rate-limit'; + +let database: sqlite3.Database; +let server: Server; +let apollo: ApolloServer; +let endpoint: string; +let account: { id: string; email: string }; +const password = 'OwnedCaptchaFixture!94'; +const store = sqliteAuthStore as unknown as { db: sqlite3.Database | null; initialized: boolean }; + +async function proof(action: CaptchaAction) { + const challenge = await createCaptchaChallenge(action); + const solution = await solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, challenge.maxnumber).promise; + if (!solution) throw new Error('Could not solve owned challenge'); + return Buffer.from(JSON.stringify({ ...challenge, number: solution.number })).toString('base64'); +} + +async function request(operation: 'login' | 'signup', input: Record) { + const response = await fetch(endpoint, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: `mutation($input:${operation === 'login' ? 'LoginInput' : 'SignupInput'}!){renamed:${operation}(input:$input){token user{id}}}`, variables: { input } }), + }); + return response.json(); +} + +beforeAll(async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('ADMIN_EMAIL', 'captcha-seed@example.invalid'); + vi.stubEnv('ADMIN_PASSWORD', 'OwnedSeedPassword!94'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + database = await new Promise((resolve, reject) => { + const connection = new sqlite3.Database(':memory:', error => error ? reject(error) : resolve(connection)); + }); + store.db = database; store.initialized = false; + await sqliteAuthStore.initialize(); + account = await sqliteAuthStore.createUser({ email: 'captcha-login@example.invalid', username: 'captcha-login', name: 'Owned CAPTCHA fixture', password, role: 'USER' }); + apollo = new ApolloServer({ typeDefs: authOnlyTypeDefs, resolvers: { Mutation: { + login: sqliteAuthResolvers.Mutation.login, signup: sqliteAuthResolvers.Mutation.signup, + } } }); + await apollo.start(); + const app = express(); + app.set('trust proxy', 2); + app.get('/challenge-budget', createCaptchaChallengeLimiter(), (_req, res) => res.json({ allowed: true })); + app.use('/graphql', express.json(), expressMiddleware(apollo, { context: async ({ req }) => ({ req }) })); + server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected an owned HTTP listener'); + endpoint = `http://127.0.0.1:${address.port}/graphql`; +}); + +afterAll(async () => { + if (apollo) await apollo.stop(); + if (server) await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + if (database) await new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); + store.db = null; store.initialized = false; + vi.restoreAllMocks(); vi.unstubAllEnvs(); +}); + +describe('production CAPTCHA at the actual HTTP GraphQL boundary', () => { + it('cannot rotate forwarded IPs to bypass the socket issuance budget with trust proxy enabled', async () => { + for (let attempt = 0; attempt < 31; attempt++) { + const response = await fetch(endpoint.replace('/graphql', '/challenge-budget'), { + headers: { 'x-forwarded-for': `198.51.100.${attempt + 1}` }, + }); + expect(response.status).toBe(attempt < 30 ? 200 : 429); + } + }); + it('refuses missing and forged proofs despite a correct password', async () => { + for (const captchaPayload of [undefined, 'ABC123']) { + const result = await request('login', { emailOrUsername: account.email, password, captchaPayload }); + expect(result.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + expect(result.data).toBeNull(); + } + }); + + it('accepts a valid proof once and rejects a proof for another action', async () => { + const input = { emailOrUsername: account.email, password }; + const wrong = await request('login', { ...input, captchaPayload: await proof('signup') }); + expect(wrong.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + const captchaPayload = await proof('login'); + const accepted = await request('login', { ...input, captchaPayload }); + expect(accepted.errors).toBeUndefined(); + expect(accepted.data.renamed.user.id).toBe(account.id); + expect(typeof accepted.data.renamed.token).toBe('string'); + const replay = await request('login', { ...input, captchaPayload }); + expect(replay.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + expect(replay.data).toBeNull(); + }, 20000); + + it('does not create an account without a valid signup proof', async () => { + const input = { email: 'captcha-signup@example.invalid', username: 'captcha-signup', password, name: 'Owned signup' }; + for (const captchaPayload of [undefined, 'ABC123']) { + const result = await request('signup', { ...input, captchaPayload }); + expect(result.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + expect(await sqliteAuthStore.findUserByEmailOrUsername(input.email)).toBeNull(); + } + const accepted = await request('signup', { ...input, captchaPayload: await proof('signup') }); + expect(accepted.errors).toBeUndefined(); + expect(accepted.data.renamed.user.id).toBeTruthy(); + expect((await sqliteAuthStore.findUserByEmailOrUsername(input.email))?.id).toBe(accepted.data.renamed.user.id); + }, 20000); +}); diff --git a/packages/server/tests/captcha.test.ts b/packages/server/tests/captcha.test.ts new file mode 100644 index 00000000..1a00c0dd --- /dev/null +++ b/packages/server/tests/captcha.test.ts @@ -0,0 +1,95 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { solveChallenge } from 'altcha-lib'; +import { randomBytes } from 'node:crypto'; +import { CaptchaService, CaptchaCapacityError, verifyCaptcha, verifyAuthCaptcha, captchaRequired, CAPTCHA_ACTIONS } from '../src/utils/captcha'; +import { validateProductionSecrets } from '../src/config/secrets'; + +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64'); +async function solve(challenge: Awaited>) { + const solution = await solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, challenge.maxnumber).promise; + if (!solution) throw new Error('Owned challenge was not solved'); + return encode({ ...challenge, number: solution.number }); +} +function fixture(options: Partial[0]> = {}) { + return new CaptchaService({ key: randomBytes(32).toString('hex'), maxNumber: 20, ...options }); +} + +afterEach(() => vi.unstubAllEnvs()); + +it('rejects arbitrary six-character client codes', async () => { + expect(await verifyCaptcha('ABC123', 'magic-link')).toBe(false); +}); + +it('rejects malformed and absent proof', async () => { + for (const payload of [undefined, null, '', 'not-a-proof', 'e30=']) { + expect(await verifyCaptcha(payload, 'magic-link')).toBe(false); + } +}); + +it('enforces production policy for every action and rejects supplied invalid development proof', async () => { + vi.stubEnv('NODE_ENV', 'production'); + for (const action of CAPTCHA_ACTIONS) { + expect(captchaRequired(action)).toBe(true); + expect(await verifyAuthCaptcha(undefined, action)).toBe(false); + expect(await verifyAuthCaptcha('ABC123', action)).toBe(false); + } + vi.stubEnv('NODE_ENV', 'development'); + expect(await verifyAuthCaptcha(undefined, 'login')).toBe(true); + expect(await verifyAuthCaptcha(undefined, 'signup')).toBe(true); + expect(await verifyAuthCaptcha(undefined, 'magic-link')).toBe(false); + expect(await verifyAuthCaptcha('ABC123', 'login')).toBe(false); +}); + +it('accepts a signed solution once, bound to its original action', async () => { + const service = fixture(); + const proof = await solve(await service.create('magic-link')); + expect(await service.verify(proof, 'reset-password')).toBe(false); + expect(await service.verify(proof, 'magic-link')).toBe(true); + expect(await service.verify(proof, 'magic-link')).toBe(false); +}); + +it('allows exactly one concurrent redemption', async () => { + const service = fixture(); + const proof = await solve(await service.create('login')); + const results = await Promise.all(Array.from({ length: 20 }, () => service.verify(proof, 'login'))); + expect(results.filter(Boolean)).toHaveLength(1); +}); + +it('rejects tampering, foreign signatures, oversized input and out-of-range numbers', async () => { + const service = fixture(); + const challenge = await service.create('signup'); + const proof = await solve(challenge); + const parsed = JSON.parse(Buffer.from(proof, 'base64').toString()); + for (const changed of [{ signature: '0'.repeat(64) }, { salt: 'other' }, { algorithm: 'SHA-1' }, { number: -1 }, { number: 21 }, { number: 0.5 }]) { + expect(await service.verify(encode({ ...parsed, ...changed }), 'signup')).toBe(false); + } + expect(await fixture().verify(proof, 'signup')).toBe(false); + expect(await service.verify('A'.repeat(4097), 'signup')).toBe(false); + expect(await service.verify(proof, 'signup')).toBe(true); +}); + +it('expires challenges and never evicts a live proof to admit more work', async () => { + let now = Date.now(); + const service = fixture({ capacity: 1, ttlMs: 1000, now: () => now }); + const proof = await solve(await service.create('login')); + await expect(service.create('login')).rejects.toBeInstanceOf(CaptchaCapacityError); + now += 1000; + expect(await service.verify(proof, 'login')).toBe(false); + const fresh = await solve(await service.create('login')); + expect(await service.verify(fresh, 'login')).toBe(true); +}); + +it('reserves issuance capacity before asynchronous challenge creation', async () => { + const service = fixture({ capacity: 2 }); + const results = await Promise.allSettled(Array.from({ length: 20 }, () => service.create('login'))); + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(2); + expect(results.filter(result => result.status === 'rejected')).toHaveLength(18); +}); + +it('rejects the old production HMAC fallback and permits an unset process-local key', () => { + const environment = { NODE_ENV: 'production', JWT_SECRET: randomBytes(32).toString('hex'), SESSION_SECRET: randomBytes(32).toString('hex') }; + expect(() => validateProductionSecrets(environment)).not.toThrow(); + for (const ALTCHA_HMAC_KEY of ['', 'short', 'your-secret-hmac-key-change-in-production']) { + expect(() => validateProductionSecrets({ ...environment, ALTCHA_HMAC_KEY })).toThrow(/ALTCHA_HMAC_KEY/); + } +}); diff --git a/packages/server/tests/development-startup.test.ts b/packages/server/tests/development-startup.test.ts new file mode 100644 index 00000000..56470f39 --- /dev/null +++ b/packages/server/tests/development-startup.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +const fixture = vi.hoisted(() => ({ envPath: '', start: vi.fn() })); +vi.mock('dotenv', async importOriginal => { + const original = await importOriginal(); + return { default: { config: () => original.config({ path: fixture.envPath }) } }; +}); + +let directory: string; +beforeEach(() => { + vi.resetModules(); vi.clearAllMocks(); + vi.doUnmock('../src/index.js'); + vi.doMock('../src/index.js', () => { fixture.start(process.env.NODE_ENV); return {}; }); + directory = mkdtempSync(join(tmpdir(), 'graphdone-dev-entry-')); + fixture.envPath = join(directory, '.env'); + writeFileSync(fixture.envPath, ''); + vi.stubEnv('NODE_ENV', 'test'); + delete process.env.NODE_ENV; + vi.stubEnv('JWT_SECRET', randomBytes(32).toString('hex')); + vi.stubEnv('SESSION_SECRET', randomBytes(32).toString('hex')); +}); +afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); + rmSync(directory, { recursive: true, force: true }); +}); + +describe('explicit development entry point', () => { + it('defaults only an unset mode to development before loading the API', async () => { + await import('../src/dev.js'); + expect(fixture.start).toHaveBeenCalledWith('development'); + const { captchaRequired } = await import('../src/utils/captcha'); + expect(captchaRequired('login')).toBe(false); + expect(captchaRequired('reset-password')).toBe(true); + }); + + it.each(['production', 'staging', ''])('preserves explicitly configured %j and required login proof', async mode => { + vi.stubEnv('NODE_ENV', mode); + await import('../src/dev.js'); + expect(fixture.start).toHaveBeenCalledWith(mode); + const { captchaRequired } = await import('../src/utils/captcha'); + expect(captchaRequired('login')).toBe(true); + }); + + it('loads a production .env before deciding whether to default the mode', async () => { + writeFileSync(fixture.envPath, 'NODE_ENV=production\n'); + await import('../src/dev.js'); + expect(fixture.start).toHaveBeenCalledWith('production'); + const { captchaRequired } = await import('../src/utils/captcha'); + expect(captchaRequired('login')).toBe(true); + }); + + it('keeps production startup secret validation before API loading', async () => { + writeFileSync(fixture.envPath, 'NODE_ENV=production\n'); + vi.stubEnv('JWT_SECRET', ''); + delete process.env.JWT_SECRET; + await expect(import('../src/dev.js')).rejects.toThrow('JWT_SECRET'); + expect(fixture.start).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/tests/production-startup.test.ts b/packages/server/tests/production-startup.test.ts index 2aad11a8..51ca95dd 100644 --- a/packages/server/tests/production-startup.test.ts +++ b/packages/server/tests/production-startup.test.ts @@ -86,6 +86,7 @@ describe('production API startup secret boundary', () => { ['blank SESSION_SECRET', 'SESSION_SECRET', ' '], ['fallback JWT_SECRET', 'JWT_SECRET', 'your-dev-secret-key'], ['fallback SESSION_SECRET', 'SESSION_SECRET', 'graphdone-default-secret-change-in-production'], + ['fallback ALTCHA_HMAC_KEY', 'ALTCHA_HMAC_KEY', 'your-secret-hmac-key-change-in-production'], ])('rejects %s before database initialization or listening', async (_label, name, value) => { if (value === undefined) delete process.env[name!]; else vi.stubEnv(name!, value); diff --git a/packages/web/src/components/AuthCaptcha.test.tsx b/packages/web/src/components/AuthCaptcha.test.tsx new file mode 100644 index 00000000..76a45698 --- /dev/null +++ b/packages/web/src/components/AuthCaptcha.test.tsx @@ -0,0 +1,99 @@ +import { Profiler } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AuthCaptcha } from './AuthCaptcha'; + +vi.mock('altcha', () => ({})); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('authentication verification UI', () => { + it('captures proof emitted at the first widget commit before passive effects', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ captcha: { provider: 'altcha' } }) })); + const verified = vi.fn(); + const readiness = vi.fn(); + const seen = new WeakSet(); + const captured: unknown[] = []; + render( { + const widget = document.querySelector('altcha-widget'); + if (widget && !seen.has(widget)) { + seen.add(widget); + widget.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'verified', payload: 'early-issued-proof' } })); + captured.push(verified.mock.calls.at(-1)?.[0]); + } + }}>); + await screen.findByTestId('server-captcha'); + expect(captured).toEqual(['early-issued-proof']); + expect(verified).toHaveBeenLastCalledWith('early-issued-proof'); + expect(readiness).toHaveBeenLastCalledWith(true); + expect(screen.getByRole('status')).toHaveTextContent('Verification complete'); + }); + + it('clears proof on expiry and retry, and requests the intended server action', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ captcha: { provider: 'altcha' } }) })); + const verified = vi.fn(); + const readiness = vi.fn(); + const { container, rerender, unmount } = render(); + expect(readiness).toHaveBeenLastCalledWith(false); + await screen.findByTestId('server-captcha'); + const widget = container.querySelector('altcha-widget')!; + expect(widget.getAttribute('challengeurl')).toContain('/api/captcha/challenge?action=login'); + act(() => widget.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'verified', payload: 'issued-proof' } }))); + expect(verified).toHaveBeenLastCalledWith('issued-proof'); + expect(readiness).toHaveBeenLastCalledWith(true); + expect(screen.getByRole('status')).toHaveTextContent('Verification complete'); + act(() => widget.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'expired' } }))); + expect(verified).toHaveBeenLastCalledWith(''); + expect(readiness).toHaveBeenLastCalledWith(false); + expect(screen.getByRole('status')).toHaveTextContent('expired'); + fireEvent.click(screen.getByRole('button', { name: 'Retry verification' })); + expect(container.querySelector('altcha-widget')).not.toBe(widget); + expect(verified).toHaveBeenLastCalledWith(''); + const replacement = container.querySelector('altcha-widget')!; + const beforeRemovedEvent = verified.mock.calls.length; + act(() => widget.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'verified', payload: 'stale-proof' } }))); + expect(verified).toHaveBeenCalledTimes(beforeRemovedEvent); + act(() => replacement.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'verified', payload: 'replacement-proof' } }))); + expect(verified).toHaveBeenLastCalledWith('replacement-proof'); + act(() => replacement.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'error' } }))); + expect(verified).toHaveBeenLastCalledWith(''); + expect(readiness).toHaveBeenLastCalledWith(false); + expect(screen.getByRole('status')).toHaveTextContent('failed'); + rerender(); + expect(container.querySelector('altcha-widget')!.getAttribute('challengeurl')).toContain('action=reset-password'); + const current = container.querySelector('altcha-widget')!; + const afterReset = verified.mock.calls.length; + act(() => replacement.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'verified', payload: 'old-action-proof' } }))); + expect(verified).toHaveBeenCalledTimes(afterReset); + unmount(); + act(() => current.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'verified', payload: 'unmounted-proof' } }))); + expect(verified).toHaveBeenCalledTimes(afterReset); + }); + + it('shows an accessible failure and retry when policy loading fails', async () => { + const fetch = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ ok: true, json: async () => ({ captcha: { provider: 'altcha' } }) }); + vi.stubGlobal('fetch', fetch); + const verified = vi.fn(); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent('Verification is unavailable'); + expect(screen.queryByLabelText('Verification answer')).toBeNull(); + expect(verified.mock.calls.every(([value]) => value === '')).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Retry verification' })); + await screen.findByTestId('server-captcha'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('retains an explicitly advertised backend without Core CAPTCHA support', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ oauth: { providers: {} } }) })); + const { container } = render( {}} />); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); + expect(container.querySelector('altcha-widget')).toBeNull(); + }); + + it('allows an explicitly optional Core action without asking for proof', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ captcha: { provider: 'altcha', requiredActions: ['magic-link'] } }) })); + const readiness = vi.fn(); + render( {}} onReadyChange={readiness} />); + await waitFor(() => expect(readiness).toHaveBeenLastCalledWith(true)); + expect(screen.queryByTestId('server-captcha')).toBeNull(); + }); +}); diff --git a/packages/web/src/components/AuthCaptcha.tsx b/packages/web/src/components/AuthCaptcha.tsx new file mode 100644 index 00000000..2f04eb10 --- /dev/null +++ b/packages/web/src/components/AuthCaptcha.tsx @@ -0,0 +1,116 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type {} from 'altcha'; +import { CodeCaptcha } from './CodeCaptcha'; + +type Action = 'login' | 'signup' | 'magic-link' | 'forgot-password' | 'reset-password'; + +/** Core advertises server verification in /config. Older/Cloud backends retain + * their existing contract; a failed policy request never becomes a success. */ +export function AuthCaptcha({ action, onVerified, onReadyChange, resetKey = 0, autoFocus = false, compact = false, className = '' }: { + action: Action; + onVerified: (payload: string) => void; + onReadyChange?: (ready: boolean) => void; + resetKey?: number; + autoFocus?: boolean; + compact?: boolean; + className?: string; +}) { + const [policy, setPolicy] = useState<'loading' | 'altcha' | 'legacy' | 'error'>('loading'); + const [ready, setReady] = useState(false); + const [requiredActions, setRequiredActions] = useState(null); + const [reload, setReload] = useState(0); + const [attempt, setAttempt] = useState(0); + const [status, setStatus] = useState('Complete verification before continuing.'); + const widget = useRef(null); + const focusOnMount = useRef(autoFocus); + const callback = useRef(onVerified); + callback.current = onVerified; + const readiness = useRef(onReadyChange); + readiness.current = onReadyChange; + const optional = policy === 'legacy' && (action === 'login' || action === 'signup') + || policy === 'altcha' && requiredActions !== null && !requiredActions.includes(action); + const apiUrl = (import.meta.env.VITE_API_URL || window.location.origin).replace(/\/$/, ''); + + useEffect(() => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15000); + let active = true; + callback.current(''); + readiness.current?.(false); + setPolicy('loading'); + void (async () => { + try { + const response = await fetch(`${apiUrl}/config`, { signal: controller.signal }); + if (!response.ok) throw new Error('Verification policy unavailable'); + const config = await response.json(); + if (config.captcha?.provider === 'altcha') { + await import('altcha'); + if (active) { + setRequiredActions(Array.isArray(config.captcha.requiredActions) ? config.captcha.requiredActions : null); + setReady(true); setPolicy('altcha'); + } + } else if (active) setPolicy('legacy'); + } catch { + if (active) setPolicy('error'); + } finally { window.clearTimeout(timeout); } + })(); + return () => { active = false; controller.abort(); window.clearTimeout(timeout); }; + }, [apiUrl, reload]); + + useLayoutEffect(() => { + callback.current(''); + readiness.current?.(optional); + setStatus('Complete verification before continuing.'); + const element = widget.current; + if (!element) return; + const changed = (event: Event) => { + const { state, payload } = (event as CustomEvent<{ state: string; payload?: string }>).detail; + callback.current(state === 'verified' && payload ? payload : ''); + readiness.current?.(optional || state === 'verified' && Boolean(payload)); + setStatus(state === 'verified' ? 'Verification complete.' + : state === 'verifying' ? 'Verifying…' + : state === 'expired' ? 'Verification expired. Please verify again.' + : state === 'error' ? 'Verification failed. Please retry.' + : 'Complete verification before continuing.'); + }; + element.addEventListener('statechange', changed); + return () => element.removeEventListener('statechange', changed); + }, [policy, ready, action, resetKey, attempt, optional]); + + useEffect(() => { + const element = widget.current; + const wideScreen = typeof window.matchMedia !== 'function' || window.matchMedia('(min-width: 1024px)').matches; + if (!element || !(focusOnMount.current && wideScreen || attempt > 0 || reload > 0)) return; + // The widget's autofocus option concerns verification completion. Focus its + // initial control once on mode entry/retry, never when the user starts typing. + const root = element.shadowRoot || element; + const focus = () => { + const input = root.querySelector('input[type="checkbox"]'); + input?.focus(); + return Boolean(input); + }; + if (focus()) return; + const observer = new MutationObserver(() => { if (focus()) observer.disconnect(); }); + observer.observe(root, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [policy, ready, action, resetKey, attempt, reload]); + + if (policy === 'loading') return

Loading verification…

; + if (policy === 'error') return
+

Verification is unavailable. Please try again.

+ +
; + if (optional) return null; + if (policy === 'legacy') return action === 'login' || action === 'signup' ? null + : ; + return
+ {ready &&
} +

{status}

+ +
; +} diff --git a/packages/web/src/pages/ForgotPassword.tsx b/packages/web/src/pages/ForgotPassword.tsx index 2261dd68..04367e70 100644 --- a/packages/web/src/pages/ForgotPassword.tsx +++ b/packages/web/src/pages/ForgotPassword.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Mail, ArrowLeft, CheckCircle, XCircle, Shield } from 'lucide-react'; import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; -import { CodeCaptcha } from '../components/CodeCaptcha'; +import { AuthCaptcha } from '../components/AuthCaptcha'; import { isValidEmail } from '../utils/validation'; export function ForgotPassword() { @@ -14,6 +14,7 @@ export function ForgotPassword() { const [rateLimitError, setRateLimitError] = useState(''); const [rateLimitRetryAfter, setRateLimitRetryAfter] = useState(null); const [captchaPayload, setCaptchaPayload] = useState(''); + const [captchaReset, setCaptchaReset] = useState(0); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -34,7 +35,7 @@ export function ForgotPassword() { setRateLimitRetryAfter(null); try { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4127'; + const apiUrl = import.meta.env.VITE_API_URL || window.location.origin; const response = await fetch(`${apiUrl}/auth/forgot-password`, { method: 'POST', headers: { @@ -60,6 +61,8 @@ export function ForgotPassword() { } catch { setError('Failed to send reset link. Please try again.'); } finally { + setCaptchaPayload(''); + setCaptchaReset(value => value + 1); setLoading(false); } }; @@ -192,9 +195,8 @@ export function ForgotPassword() { {/* CAPTCHA */} - setCaptchaPayload(code)} - onError={() => setCaptchaPayload('')} /> {/* Submit Button */} diff --git a/packages/web/src/pages/ResetPassword.tsx b/packages/web/src/pages/ResetPassword.tsx index 1648a3e3..fba9feae 100644 --- a/packages/web/src/pages/ResetPassword.tsx +++ b/packages/web/src/pages/ResetPassword.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useNavigate, Link } from 'react-router-dom'; import { Lock, Eye, EyeOff, CheckCircle, XCircle, ArrowLeft } from 'lucide-react'; import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; -import { CodeCaptcha } from '../components/CodeCaptcha'; +import { AuthCaptcha } from '../components/AuthCaptcha'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { validatePassword, getPasswordStrength } from '../utils/validation'; @@ -20,6 +20,7 @@ export function ResetPassword() { const [error, setError] = useState(''); const [passwordsMatch, setPasswordsMatch] = useState(null); const [captchaPayload, setCaptchaPayload] = useState(''); + const [captchaReset, setCaptchaReset] = useState(0); useEffect(() => { if (!token) { @@ -47,7 +48,7 @@ export function ResetPassword() { setError(''); try { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4127'; + const apiUrl = import.meta.env.VITE_API_URL || window.location.origin; const response = await fetch(`${apiUrl}/auth/reset-password`, { method: 'POST', headers: { @@ -69,6 +70,8 @@ export function ResetPassword() { } catch { setError('Failed to reset password. Please try again.'); } finally { + setCaptchaPayload(''); + setCaptchaReset(value => value + 1); setLoading(false); } }; @@ -218,9 +221,8 @@ export function ResetPassword() { {/* CAPTCHA */} - setCaptchaPayload(code)} - onError={() => setCaptchaPayload('')} />