@astram/guard 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/audit-chain-sealer.d.ts +90 -0
- package/dist/audit/audit-chain-sealer.d.ts.map +1 -0
- package/dist/audit/audit-chain-sealer.js +520 -0
- package/dist/audit/audit-seal-config.d.ts +22 -0
- package/dist/audit/audit-seal-config.d.ts.map +1 -0
- package/dist/audit/audit-seal-config.js +35 -0
- package/dist/audit/audit-sealer.service.d.ts +62 -0
- package/dist/audit/audit-sealer.service.d.ts.map +1 -0
- package/dist/audit/audit-sealer.service.js +197 -0
- package/dist/audit/node-sha256-hash.d.ts +6 -0
- package/dist/audit/node-sha256-hash.d.ts.map +1 -0
- package/dist/audit/node-sha256-hash.js +16 -0
- package/dist/auth/account.controller.d.ts +3 -1
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +79 -6
- package/dist/auth/admin-signin-controls.d.ts +80 -0
- package/dist/auth/admin-signin-controls.d.ts.map +1 -0
- package/dist/auth/admin-signin-controls.js +91 -0
- package/dist/auth/auth-audit.d.ts +59 -0
- package/dist/auth/auth-audit.d.ts.map +1 -0
- package/dist/auth/auth-audit.js +93 -0
- package/dist/auth/auth.controller.d.ts +53 -6
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +330 -44
- package/dist/auth/auth.module.d.ts +49 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +149 -4
- package/dist/auth/captcha-gate.d.ts +103 -0
- package/dist/auth/captcha-gate.d.ts.map +1 -0
- package/dist/auth/captcha-gate.js +245 -0
- package/dist/auth/dto.d.ts +22 -1
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/login-name-count-boot.d.ts +24 -0
- package/dist/auth/login-name-count-boot.d.ts.map +1 -0
- package/dist/auth/login-name-count-boot.js +47 -0
- package/dist/auth/logout-proof.d.ts +55 -0
- package/dist/auth/logout-proof.d.ts.map +1 -0
- package/dist/auth/logout-proof.js +127 -0
- package/dist/auth/pending-mfa-store.d.ts +5 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +2 -0
- package/dist/auth/persistence.module.d.ts +2 -2
- package/dist/auth/persistence.module.d.ts.map +1 -1
- package/dist/auth/persistence.module.js +19 -3
- package/dist/auth/ports/audit-sink.d.ts +5 -2
- package/dist/auth/ports/audit-sink.d.ts.map +1 -1
- package/dist/auth/ports/mfa-device-store.d.ts +7 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -1
- package/dist/auth/profile-resolver.d.ts +6 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.d.ts +6 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.js +14 -0
- package/dist/auth/tokens.d.ts +59 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +61 -1
- package/dist/authentication/credential-provisioner.d.ts +7 -0
- package/dist/authentication/credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.d.ts +1 -0
- package/dist/authentication/mariadb-credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.js +7 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +23 -2
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +45 -1
- package/dist/authentication/mariadb-mfa-device-store.d.ts +7 -0
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +32 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts +55 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-requirement-source.js +164 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts +42 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-resetter.js +59 -0
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +5 -0
- package/dist/authentication/sign-in-with.d.ts +14 -0
- package/dist/authentication/sign-in-with.d.ts.map +1 -0
- package/dist/authentication/sign-in-with.js +36 -0
- package/dist/authz/core-access.d.ts +11 -0
- package/dist/authz/core-access.d.ts.map +1 -0
- package/dist/authz/core-access.js +14 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -1
- package/dist/authz/list-scope-sql.js +15 -5
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +21 -3
- package/dist/captcha/captcha.providers.d.ts +18 -0
- package/dist/captcha/captcha.providers.d.ts.map +1 -0
- package/dist/captcha/captcha.providers.js +78 -0
- package/dist/captcha/recaptcha-verifier.d.ts +20 -0
- package/dist/captcha/recaptcha-verifier.d.ts.map +1 -0
- package/dist/captcha/recaptcha-verifier.js +48 -0
- package/dist/captcha/siteverify.d.ts +47 -0
- package/dist/captcha/siteverify.d.ts.map +1 -0
- package/dist/captcha/siteverify.js +98 -0
- package/dist/captcha/turnstile-verifier.d.ts +18 -0
- package/dist/captcha/turnstile-verifier.d.ts.map +1 -0
- package/dist/captcha/turnstile-verifier.js +41 -0
- package/dist/config/captcha-config.d.ts +47 -0
- package/dist/config/captcha-config.d.ts.map +1 -0
- package/dist/config/captcha-config.js +163 -0
- package/dist/config/reserved-principal-ids.d.ts +15 -0
- package/dist/config/reserved-principal-ids.d.ts.map +1 -1
- package/dist/config/reserved-principal-ids.js +20 -3
- package/dist/enforcement/global-enforcement.guard.d.ts +9 -1
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +52 -10
- package/dist/enforcement/reenroll-allowlist.d.ts +5 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -1
- package/dist/enforcement/reenroll-allowlist.js +5 -0
- package/dist/identity/auth-facts.controller.d.ts +31 -4
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/auth-facts.controller.js +46 -11
- package/dist/identity/identity-actions.d.ts +2 -2
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.js +3 -6
- package/dist/identity/permission-facts.adapter.d.ts +22 -0
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +26 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +43 -11
- package/dist/internal.d.ts +27 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +36 -1
- package/dist/module/shield.module.d.ts +27 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +10 -0
- package/dist/request-auth/request-principal.d.ts +8 -1
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +11 -4
- package/dist/request-auth/session-token-binder.d.ts +82 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +203 -1
- package/dist/session/mfa-requirement-service.d.ts +168 -0
- package/dist/session/mfa-requirement-service.d.ts.map +1 -0
- package/dist/session/mfa-requirement-service.js +461 -0
- package/dist/session/session-restriction-store.d.ts +36 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -1
- package/dist/session/session-restriction-store.js +57 -6
- package/dist/session/session-restriction.d.ts +22 -0
- package/dist/session/session-restriction.d.ts.map +1 -0
- package/dist/session/session-restriction.js +23 -0
- package/package.json +10 -5
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildCaptchaVerifier = buildCaptchaVerifier;
|
|
4
|
+
exports.captchaProviders = captchaProviders;
|
|
5
|
+
/**
|
|
6
|
+
* `AuthModule` providers for the `captcha` option (auth capabilities R7.1, D-6):
|
|
7
|
+
* - absent ⇒ `SHIELD_CAPTCHA_CONFIG` and `SHIELD_CAPTCHA_GATE` bound `null` (every route as today,
|
|
8
|
+
* no `CAPTCHA_*` variable read);
|
|
9
|
+
* - `'env'` ⇒ the config from `CAPTCHA_*` (`loadCaptchaConfig`, boot fails on a bad value), the
|
|
10
|
+
* provider's verifier, and the gate over the guard's Redis client and the auth audit sink. One
|
|
11
|
+
* boot line names the provider, mode, fail mode and endpoints (never the keys).
|
|
12
|
+
*/
|
|
13
|
+
const common_1 = require("@nestjs/common");
|
|
14
|
+
const captcha_config_js_1 = require("../config/captcha-config.js");
|
|
15
|
+
const boot_secrets_js_1 = require("../secrets/boot-secrets.js");
|
|
16
|
+
const captcha_gate_js_1 = require("../auth/captcha-gate.js");
|
|
17
|
+
const tokens_js_1 = require("../auth/tokens.js");
|
|
18
|
+
const recaptcha_verifier_js_1 = require("./recaptcha-verifier.js");
|
|
19
|
+
const turnstile_verifier_js_1 = require("./turnstile-verifier.js");
|
|
20
|
+
/** Build the adapter named by `CAPTCHA_PROVIDER`. */
|
|
21
|
+
function buildCaptchaVerifier(config) {
|
|
22
|
+
if (config.provider === 'turnstile') {
|
|
23
|
+
return new turnstile_verifier_js_1.TurnstileVerifier({
|
|
24
|
+
secret: config.secret,
|
|
25
|
+
expectedHostnames: config.expectedHostnames,
|
|
26
|
+
timeoutMs: config.timeoutMs,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return new recaptcha_verifier_js_1.RecaptchaVerifier({
|
|
30
|
+
version: config.provider === 'recaptcha-v3' ? 'v3' : 'v2',
|
|
31
|
+
secret: config.secret,
|
|
32
|
+
expectedHostnames: config.expectedHostnames,
|
|
33
|
+
minScore: config.minScore,
|
|
34
|
+
timeoutMs: config.timeoutMs,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** Providers for one `AuthModule` registration (see the file header). */
|
|
38
|
+
function captchaProviders(option) {
|
|
39
|
+
if (option !== undefined && option !== 'env') {
|
|
40
|
+
throw new Error("ShieldModule: `captcha` must be 'env' (or omitted)");
|
|
41
|
+
}
|
|
42
|
+
if (option === undefined) {
|
|
43
|
+
return [
|
|
44
|
+
{ provide: tokens_js_1.SHIELD_CAPTCHA_CONFIG, useValue: null },
|
|
45
|
+
{ provide: tokens_js_1.SHIELD_CAPTCHA_GATE, useValue: null },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
provide: tokens_js_1.SHIELD_CAPTCHA_CONFIG,
|
|
51
|
+
useFactory: () => (0, captcha_config_js_1.loadCaptchaConfig)(process.env, { resolvedFromSecretRef: boot_secrets_js_1.wasResolvedFromSecretRef }),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
provide: tokens_js_1.SHIELD_CAPTCHA_VERIFIER,
|
|
55
|
+
useFactory: (config) => buildCaptchaVerifier(config),
|
|
56
|
+
inject: [tokens_js_1.SHIELD_CAPTCHA_CONFIG],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
provide: tokens_js_1.SHIELD_CAPTCHA_GATE,
|
|
60
|
+
useFactory: (config, verifier, redis, auditSink) => {
|
|
61
|
+
new common_1.Logger('ShieldCaptcha').log(`CAPTCHA on: provider=${config.provider} mode=${config.mode} ` +
|
|
62
|
+
`failMode=${config.failMode} endpoints=${config.endpoints.join(',')}`);
|
|
63
|
+
return new captcha_gate_js_1.CaptchaGate({
|
|
64
|
+
config,
|
|
65
|
+
verifier,
|
|
66
|
+
counters: new captcha_gate_js_1.RedisCaptchaCounterStore(redis),
|
|
67
|
+
auditSink,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
inject: [
|
|
71
|
+
tokens_js_1.SHIELD_CAPTCHA_CONFIG,
|
|
72
|
+
tokens_js_1.SHIELD_CAPTCHA_VERIFIER,
|
|
73
|
+
tokens_js_1.SHIELD_REDIS,
|
|
74
|
+
{ token: tokens_js_1.SHIELD_AUDIT_SINK, optional: true },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CancellationSignal, CaptchaVerifier, CaptchaVerifyRequest, CaptchaVerifyResult } from '@astram/core';
|
|
2
|
+
import { type CaptchaLogger } from './siteverify.js';
|
|
3
|
+
export declare const RECAPTCHA_SITEVERIFY_URL = "https://www.google.com/recaptcha/api/siteverify";
|
|
4
|
+
export interface RecaptchaVerifierOptions {
|
|
5
|
+
readonly version: 'v3' | 'v2';
|
|
6
|
+
readonly secret: string;
|
|
7
|
+
readonly expectedHostnames: readonly string[];
|
|
8
|
+
/** v3 only. */
|
|
9
|
+
readonly minScore: number;
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
readonly fetch?: typeof fetch;
|
|
12
|
+
readonly logger?: CaptchaLogger;
|
|
13
|
+
}
|
|
14
|
+
export declare class RecaptchaVerifier implements CaptchaVerifier {
|
|
15
|
+
private readonly opts;
|
|
16
|
+
private readonly logger;
|
|
17
|
+
constructor(opts: RecaptchaVerifierOptions);
|
|
18
|
+
verify(request: CaptchaVerifyRequest, signal?: CancellationSignal): Promise<CaptchaVerifyResult>;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=recaptcha-verifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recaptcha-verifier.d.ts","sourceRoot":"","sources":["../../src/captcha/recaptcha-verifier.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,kBAAkB,EAClB,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,KAAK,aAAa,EAKnB,MAAM,iBAAiB,CAAC;AAEzB,eAAO,MAAM,wBAAwB,oDAAoD,CAAC;AAE1F,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,OAAO,EAAE,IAAI,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C,eAAe;IACf,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;CACjC;AAED,qBAAa,iBAAkB,YAAW,eAAe;IAG3C,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;gBAEV,IAAI,EAAE,wBAAwB;IAIrD,MAAM,CACV,OAAO,EAAE,oBAAoB,EAC7B,MAAM,CAAC,EAAE,kBAAkB,GAC1B,OAAO,CAAC,mBAAmB,CAAC;CA2BhC"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RecaptchaVerifier = exports.RECAPTCHA_SITEVERIFY_URL = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Google reCAPTCHA adapter for the core `CaptchaVerifier` port (auth capabilities R7.1, R7.4,
|
|
6
|
+
* AC-14). v3: `success`, hostname, action equal to the endpoint and `score ≥ CAPTCHA_MIN_SCORE`
|
|
7
|
+
* (an equal score passes, a missing score fails). v2 (checkbox): `success` and hostname only; v2
|
|
8
|
+
* reports no action or score. See `siteverify.ts` for transport rules.
|
|
9
|
+
*/
|
|
10
|
+
const common_1 = require("@nestjs/common");
|
|
11
|
+
const siteverify_js_1 = require("./siteverify.js");
|
|
12
|
+
exports.RECAPTCHA_SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
|
13
|
+
class RecaptchaVerifier {
|
|
14
|
+
opts;
|
|
15
|
+
logger;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.opts = opts;
|
|
18
|
+
this.logger = opts.logger ?? new common_1.Logger('ShieldCaptcha');
|
|
19
|
+
}
|
|
20
|
+
async verify(request, signal) {
|
|
21
|
+
const pre = (0, siteverify_js_1.precheckToken)(request.token);
|
|
22
|
+
if (pre !== null)
|
|
23
|
+
return { ok: false, reason: pre };
|
|
24
|
+
const answer = await (0, siteverify_js_1.siteverify)({
|
|
25
|
+
url: exports.RECAPTCHA_SITEVERIFY_URL,
|
|
26
|
+
secret: this.opts.secret,
|
|
27
|
+
timeoutMs: this.opts.timeoutMs,
|
|
28
|
+
...(this.opts.fetch ? { fetch: this.opts.fetch } : {}),
|
|
29
|
+
}, request.token, request.remoteIp, signal);
|
|
30
|
+
if (answer === null)
|
|
31
|
+
return { ok: false, reason: 'unavailable' };
|
|
32
|
+
if (!answer.success)
|
|
33
|
+
return (0, siteverify_js_1.failureOf)(answer, this.logger, 'reCAPTCHA');
|
|
34
|
+
if (!(0, siteverify_js_1.hostnameAllowed)(answer.hostname, this.opts.expectedHostnames)) {
|
|
35
|
+
return { ok: false, reason: 'hostname' };
|
|
36
|
+
}
|
|
37
|
+
const hostname = answer.hostname ?? '';
|
|
38
|
+
if (this.opts.version === 'v2')
|
|
39
|
+
return { ok: true, hostname };
|
|
40
|
+
if (answer.action !== request.expectedAction)
|
|
41
|
+
return { ok: false, reason: 'action' };
|
|
42
|
+
if (answer.score === undefined || answer.score < this.opts.minScore) {
|
|
43
|
+
return { ok: false, reason: 'score' };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, hostname, action: answer.action, score: answer.score };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.RecaptchaVerifier = RecaptchaVerifier;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared siteverify call for the CAPTCHA adapters (auth capabilities R7.1, R7.4; D-4: global
|
|
3
|
+
* `fetch`, no new dependency).
|
|
4
|
+
*
|
|
5
|
+
* Both Cloudflare Turnstile (`challenges.cloudflare.com/turnstile/v0/siteverify`) and Google
|
|
6
|
+
* reCAPTCHA (`www.google.com/recaptcha/api/siteverify`) take a form-encoded POST of `secret`,
|
|
7
|
+
* `response` and optional `remoteip`, and answer JSON with `success`, `hostname`, `action`,
|
|
8
|
+
* (reCAPTCHA v3) `score` and `error-codes`. A token is single use: a second check answers
|
|
9
|
+
* `timeout-or-duplicate` (also for an expired token).
|
|
10
|
+
*
|
|
11
|
+
* The call is bounded by `timeoutMs` (its own AbortController, chained to the caller's signal).
|
|
12
|
+
* Any transport error, timeout, non-2xx status or unreadable body is `unavailable`; nothing thrown
|
|
13
|
+
* escapes. Nothing here logs the secret, the token or the provider's text.
|
|
14
|
+
*/
|
|
15
|
+
import type { CancellationSignal, CaptchaFailureReason, CaptchaVerifyResult } from '@astram/core';
|
|
16
|
+
/** Tokens are at most 2048 characters (Turnstile); anything far longer is refused locally. */
|
|
17
|
+
export declare const MAX_CAPTCHA_TOKEN_LENGTH = 4096;
|
|
18
|
+
/** The minimal logger the adapters use (Nest `Logger` satisfies it). */
|
|
19
|
+
export interface CaptchaLogger {
|
|
20
|
+
warn(message: string): void;
|
|
21
|
+
}
|
|
22
|
+
export interface SiteverifyDeps {
|
|
23
|
+
readonly url: string;
|
|
24
|
+
readonly secret: string;
|
|
25
|
+
readonly timeoutMs: number;
|
|
26
|
+
readonly fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
/** The parsed provider answer (fields read defensively). */
|
|
29
|
+
export interface SiteverifyAnswer {
|
|
30
|
+
readonly success: boolean;
|
|
31
|
+
readonly hostname?: string;
|
|
32
|
+
readonly action?: string;
|
|
33
|
+
readonly score?: number;
|
|
34
|
+
readonly errorCodes: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
/** Local token check before any provider call. `null` = looks fine. */
|
|
37
|
+
export declare function precheckToken(token: unknown): CaptchaFailureReason | null;
|
|
38
|
+
/** POST the token; `null` = unavailable. */
|
|
39
|
+
export declare function siteverify(deps: SiteverifyDeps, token: string, remoteIp: string | undefined, signal: CancellationSignal | undefined): Promise<SiteverifyAnswer | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Map a failed provider answer to a result. A bad secret warns once per call (no value) and is a
|
|
42
|
+
* rejection, so `CAPTCHA_FAIL_MODE=open` can never turn a broken config into a pass.
|
|
43
|
+
*/
|
|
44
|
+
export declare function failureOf(answer: SiteverifyAnswer, logger: CaptchaLogger, providerLabel: string): CaptchaVerifyResult;
|
|
45
|
+
/** `true` when the hostname is allowed (an empty list = not checked; development/test only). */
|
|
46
|
+
export declare function hostnameAllowed(hostname: string | undefined, expected: readonly string[]): boolean;
|
|
47
|
+
//# sourceMappingURL=siteverify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"siteverify.d.ts","sourceRoot":"","sources":["../../src/captcha/siteverify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAElG,8FAA8F;AAC9F,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAE7C,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CAC/B;AAED,4DAA4D;AAC5D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAOD,uEAAuE;AACvE,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,oBAAoB,GAAG,IAAI,CAIzE;AAED,4CAA4C;AAC5C,wBAAsB,UAAU,CAC9B,IAAI,EAAE,cAAc,EACpB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,kBAAkB,GAAG,SAAS,GACrC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CA6ClC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,gBAAgB,EACxB,MAAM,EAAE,aAAa,EACrB,aAAa,EAAE,MAAM,GACpB,mBAAmB,CAarB;AAED,gGAAgG;AAChG,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,OAAO,CAGT"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_CAPTCHA_TOKEN_LENGTH = void 0;
|
|
4
|
+
exports.precheckToken = precheckToken;
|
|
5
|
+
exports.siteverify = siteverify;
|
|
6
|
+
exports.failureOf = failureOf;
|
|
7
|
+
exports.hostnameAllowed = hostnameAllowed;
|
|
8
|
+
/** Tokens are at most 2048 characters (Turnstile); anything far longer is refused locally. */
|
|
9
|
+
exports.MAX_CAPTCHA_TOKEN_LENGTH = 4096;
|
|
10
|
+
/** Error codes that mean the operator's secret is wrong: a rejection, never fail-open. */
|
|
11
|
+
const SECRET_ERRORS = new Set(['missing-input-secret', 'invalid-input-secret']);
|
|
12
|
+
/** Error codes that mean the provider failed: `unavailable`. */
|
|
13
|
+
const PROVIDER_ERRORS = new Set(['internal-error']);
|
|
14
|
+
/** Local token check before any provider call. `null` = looks fine. */
|
|
15
|
+
function precheckToken(token) {
|
|
16
|
+
if (typeof token !== 'string' || token.trim() === '')
|
|
17
|
+
return 'missing';
|
|
18
|
+
if (token.length > exports.MAX_CAPTCHA_TOKEN_LENGTH)
|
|
19
|
+
return 'invalid';
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** POST the token; `null` = unavailable. */
|
|
23
|
+
async function siteverify(deps, token, remoteIp, signal) {
|
|
24
|
+
const ctl = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => ctl.abort(), deps.timeoutMs);
|
|
26
|
+
const onAbort = () => ctl.abort();
|
|
27
|
+
if (signal?.aborted === true)
|
|
28
|
+
ctl.abort();
|
|
29
|
+
else
|
|
30
|
+
signal?.addEventListener('abort', onAbort);
|
|
31
|
+
try {
|
|
32
|
+
const form = new URLSearchParams({ secret: deps.secret, response: token });
|
|
33
|
+
if (typeof remoteIp === 'string' && remoteIp !== '')
|
|
34
|
+
form.set('remoteip', remoteIp);
|
|
35
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
36
|
+
const res = await Promise.race([
|
|
37
|
+
doFetch(deps.url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
40
|
+
body: form.toString(),
|
|
41
|
+
signal: ctl.signal,
|
|
42
|
+
}),
|
|
43
|
+
// Settles even when a fetch ignores the abort.
|
|
44
|
+
new Promise((_resolve, reject) => {
|
|
45
|
+
if (ctl.signal.aborted)
|
|
46
|
+
reject(new Error('aborted'));
|
|
47
|
+
ctl.signal.addEventListener('abort', () => reject(new Error('aborted')));
|
|
48
|
+
}),
|
|
49
|
+
]);
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
return null;
|
|
52
|
+
const body = (await res.json());
|
|
53
|
+
if (body === null || typeof body !== 'object' || typeof body.success !== 'boolean') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const codes = Array.isArray(body['error-codes'])
|
|
57
|
+
? body['error-codes'].filter((c) => typeof c === 'string')
|
|
58
|
+
: [];
|
|
59
|
+
return {
|
|
60
|
+
success: body.success,
|
|
61
|
+
...(typeof body.hostname === 'string' ? { hostname: body.hostname } : {}),
|
|
62
|
+
...(typeof body.action === 'string' ? { action: body.action } : {}),
|
|
63
|
+
...(typeof body.score === 'number' && Number.isFinite(body.score)
|
|
64
|
+
? { score: body.score }
|
|
65
|
+
: {}),
|
|
66
|
+
errorCodes: codes,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Map a failed provider answer to a result. A bad secret warns once per call (no value) and is a
|
|
78
|
+
* rejection, so `CAPTCHA_FAIL_MODE=open` can never turn a broken config into a pass.
|
|
79
|
+
*/
|
|
80
|
+
function failureOf(answer, logger, providerLabel) {
|
|
81
|
+
if (answer.errorCodes.some((c) => PROVIDER_ERRORS.has(c))) {
|
|
82
|
+
return { ok: false, reason: 'unavailable' };
|
|
83
|
+
}
|
|
84
|
+
if (answer.errorCodes.some((c) => SECRET_ERRORS.has(c))) {
|
|
85
|
+
logger.warn(`${providerLabel} rejected CAPTCHA_SECRET (missing or invalid secret); every challenge fails ` +
|
|
86
|
+
'until it is fixed');
|
|
87
|
+
return { ok: false, reason: 'invalid' };
|
|
88
|
+
}
|
|
89
|
+
if (answer.errorCodes.includes('timeout-or-duplicate'))
|
|
90
|
+
return { ok: false, reason: 'reused' };
|
|
91
|
+
return { ok: false, reason: 'invalid' };
|
|
92
|
+
}
|
|
93
|
+
/** `true` when the hostname is allowed (an empty list = not checked; development/test only). */
|
|
94
|
+
function hostnameAllowed(hostname, expected) {
|
|
95
|
+
if (expected.length === 0)
|
|
96
|
+
return true;
|
|
97
|
+
return typeof hostname === 'string' && expected.includes(hostname.toLowerCase());
|
|
98
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CancellationSignal, CaptchaVerifier, CaptchaVerifyRequest, CaptchaVerifyResult } from '@astram/core';
|
|
2
|
+
import { type CaptchaLogger } from './siteverify.js';
|
|
3
|
+
export declare const TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
4
|
+
export interface TurnstileVerifierOptions {
|
|
5
|
+
readonly secret: string;
|
|
6
|
+
readonly expectedHostnames: readonly string[];
|
|
7
|
+
readonly timeoutMs: number;
|
|
8
|
+
/** Injected in tests; defaults to global `fetch`. */
|
|
9
|
+
readonly fetch?: typeof fetch;
|
|
10
|
+
readonly logger?: CaptchaLogger;
|
|
11
|
+
}
|
|
12
|
+
export declare class TurnstileVerifier implements CaptchaVerifier {
|
|
13
|
+
private readonly opts;
|
|
14
|
+
private readonly logger;
|
|
15
|
+
constructor(opts: TurnstileVerifierOptions);
|
|
16
|
+
verify(request: CaptchaVerifyRequest, signal?: CancellationSignal): Promise<CaptchaVerifyResult>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=turnstile-verifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turnstile-verifier.d.ts","sourceRoot":"","sources":["../../src/captcha/turnstile-verifier.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,kBAAkB,EAClB,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,KAAK,aAAa,EAKnB,MAAM,iBAAiB,CAAC;AAEzB,eAAO,MAAM,wBAAwB,8DAA8D,CAAC;AAEpG,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,qDAAqD;IACrD,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;CACjC;AAED,qBAAa,iBAAkB,YAAW,eAAe;IAG3C,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;gBAEV,IAAI,EAAE,wBAAwB;IAIrD,MAAM,CACV,OAAO,EAAE,oBAAoB,EAC7B,MAAM,CAAC,EAAE,kBAAkB,GAC1B,OAAO,CAAC,mBAAmB,CAAC;CAsBhC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TurnstileVerifier = exports.TURNSTILE_SITEVERIFY_URL = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Cloudflare Turnstile adapter for the core `CaptchaVerifier` port (auth capabilities R7.1, R7.4,
|
|
6
|
+
* AC-14). Checks `success`, the hostname against `CAPTCHA_EXPECTED_HOSTNAMES` and the action
|
|
7
|
+
* (the widget's `data-action`) against the endpoint. See `siteverify.ts` for transport rules.
|
|
8
|
+
*/
|
|
9
|
+
const common_1 = require("@nestjs/common");
|
|
10
|
+
const siteverify_js_1 = require("./siteverify.js");
|
|
11
|
+
exports.TURNSTILE_SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
12
|
+
class TurnstileVerifier {
|
|
13
|
+
opts;
|
|
14
|
+
logger;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.opts = opts;
|
|
17
|
+
this.logger = opts.logger ?? new common_1.Logger('ShieldCaptcha');
|
|
18
|
+
}
|
|
19
|
+
async verify(request, signal) {
|
|
20
|
+
const pre = (0, siteverify_js_1.precheckToken)(request.token);
|
|
21
|
+
if (pre !== null)
|
|
22
|
+
return { ok: false, reason: pre };
|
|
23
|
+
const answer = await (0, siteverify_js_1.siteverify)({
|
|
24
|
+
url: exports.TURNSTILE_SITEVERIFY_URL,
|
|
25
|
+
secret: this.opts.secret,
|
|
26
|
+
timeoutMs: this.opts.timeoutMs,
|
|
27
|
+
...(this.opts.fetch ? { fetch: this.opts.fetch } : {}),
|
|
28
|
+
}, request.token, request.remoteIp, signal);
|
|
29
|
+
if (answer === null)
|
|
30
|
+
return { ok: false, reason: 'unavailable' };
|
|
31
|
+
if (!answer.success)
|
|
32
|
+
return (0, siteverify_js_1.failureOf)(answer, this.logger, 'Turnstile');
|
|
33
|
+
if (!(0, siteverify_js_1.hostnameAllowed)(answer.hostname, this.opts.expectedHostnames)) {
|
|
34
|
+
return { ok: false, reason: 'hostname' };
|
|
35
|
+
}
|
|
36
|
+
if (answer.action !== request.expectedAction)
|
|
37
|
+
return { ok: false, reason: 'action' };
|
|
38
|
+
return { ok: true, hostname: answer.hostname ?? '', action: answer.action };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.TurnstileVerifier = TurnstileVerifier;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CAPTCHA config (auth capabilities R7.1, AC-15): what `ShieldModule.forRoot({ captcha: 'env' })`
|
|
3
|
+
* reads from the environment. Mirrors {@link loadMessageConfig}: every missing or invalid value
|
|
4
|
+
* refuses boot with a message that names the variable, never its value (Shield #1, #10); outside
|
|
5
|
+
* development/test `CAPTCHA_SECRET` must have been resolved from a `secret-ref:` at boot
|
|
6
|
+
* (ADR-0032) and `CAPTCHA_EXPECTED_HOSTNAMES` is required.
|
|
7
|
+
*
|
|
8
|
+
* A blank variable counts as unset.
|
|
9
|
+
*/
|
|
10
|
+
import { type CaptchaProvider } from '@astram/core';
|
|
11
|
+
/** The endpoints a challenge can protect (Phase 1), and their provider action names (R7.4). */
|
|
12
|
+
export declare const CAPTCHA_ENDPOINTS: readonly ["login", "forgot_password", "reset_password"];
|
|
13
|
+
export type CaptchaEndpoint = (typeof CAPTCHA_ENDPOINTS)[number];
|
|
14
|
+
export declare const CAPTCHA_MODES: readonly ["adaptive", "always"];
|
|
15
|
+
export type CaptchaMode = (typeof CAPTCHA_MODES)[number];
|
|
16
|
+
export declare const CAPTCHA_FAIL_MODES: readonly ["closed", "open"];
|
|
17
|
+
export type CaptchaFailMode = (typeof CAPTCHA_FAIL_MODES)[number];
|
|
18
|
+
export declare const DEFAULT_CAPTCHA_THRESHOLD = 3;
|
|
19
|
+
export declare const MAX_CAPTCHA_THRESHOLD = 20;
|
|
20
|
+
export declare const DEFAULT_CAPTCHA_MIN_SCORE = 0.5;
|
|
21
|
+
export declare const DEFAULT_CAPTCHA_TIMEOUT_MS = 5000;
|
|
22
|
+
export declare const MAX_CAPTCHA_TIMEOUT_MS = 10000;
|
|
23
|
+
export interface CaptchaConfig {
|
|
24
|
+
readonly provider: CaptchaProvider;
|
|
25
|
+
/** Public; served by `GET /auth/login-options`. */
|
|
26
|
+
readonly siteKey: string;
|
|
27
|
+
/** Never logged, never served. */
|
|
28
|
+
readonly secret: string;
|
|
29
|
+
/** Lower-case; empty only in development/test (hostname then not checked). */
|
|
30
|
+
readonly expectedHostnames: readonly string[];
|
|
31
|
+
readonly mode: CaptchaMode;
|
|
32
|
+
readonly threshold: number;
|
|
33
|
+
readonly minScore: number;
|
|
34
|
+
readonly failMode: CaptchaFailMode;
|
|
35
|
+
readonly timeoutMs: number;
|
|
36
|
+
readonly endpoints: readonly CaptchaEndpoint[];
|
|
37
|
+
}
|
|
38
|
+
export interface LoadCaptchaConfigOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Provenance check for `CAPTCHA_SECRET` outside development/test (`wasResolvedFromSecretRef`).
|
|
41
|
+
* Omitted there: fail closed, the secret counts as a literal and is refused.
|
|
42
|
+
*/
|
|
43
|
+
readonly resolvedFromSecretRef?: (envKey: string, env: Readonly<Record<string, string | undefined>>) => boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Read the CAPTCHA config for `captcha: 'env'` (see the file header). */
|
|
46
|
+
export declare function loadCaptchaConfig(env: NodeJS.ProcessEnv, options?: LoadCaptchaConfigOptions): CaptchaConfig;
|
|
47
|
+
//# sourceMappingURL=captcha-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captcha-config.d.ts","sourceRoot":"","sources":["../../src/config/captcha-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAkC,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAIpF,+FAA+F;AAC/F,eAAO,MAAM,iBAAiB,yDAInB,CAAC;AACZ,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE,eAAO,MAAM,aAAa,iCAAiD,CAAC;AAC5E,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzD,eAAO,MAAM,kBAAkB,6BAA6C,CAAC;AAC7E,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAElE,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAC3C,eAAO,MAAM,qBAAqB,KAAK,CAAC;AACxC,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAC7C,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAChD,eAAO,MAAM,sBAAsB,QAAS,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,kCAAkC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,8EAA8E;IAC9E,QAAQ,CAAC,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;CAChD;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAC/B,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,KAC9C,OAAO,CAAC;CACd;AAYD,0EAA0E;AAC1E,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,CAAC,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,aAAa,CAmCf"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_CAPTCHA_TIMEOUT_MS = exports.DEFAULT_CAPTCHA_TIMEOUT_MS = exports.DEFAULT_CAPTCHA_MIN_SCORE = exports.MAX_CAPTCHA_THRESHOLD = exports.DEFAULT_CAPTCHA_THRESHOLD = exports.CAPTCHA_FAIL_MODES = exports.CAPTCHA_MODES = exports.CAPTCHA_ENDPOINTS = void 0;
|
|
4
|
+
exports.loadCaptchaConfig = loadCaptchaConfig;
|
|
5
|
+
/**
|
|
6
|
+
* CAPTCHA config (auth capabilities R7.1, AC-15): what `ShieldModule.forRoot({ captcha: 'env' })`
|
|
7
|
+
* reads from the environment. Mirrors {@link loadMessageConfig}: every missing or invalid value
|
|
8
|
+
* refuses boot with a message that names the variable, never its value (Shield #1, #10); outside
|
|
9
|
+
* development/test `CAPTCHA_SECRET` must have been resolved from a `secret-ref:` at boot
|
|
10
|
+
* (ADR-0032) and `CAPTCHA_EXPECTED_HOSTNAMES` is required.
|
|
11
|
+
*
|
|
12
|
+
* A blank variable counts as unset.
|
|
13
|
+
*/
|
|
14
|
+
const core_1 = require("@astram/core");
|
|
15
|
+
const app_config_js_1 = require("./app-config.js");
|
|
16
|
+
/** The endpoints a challenge can protect (Phase 1), and their provider action names (R7.4). */
|
|
17
|
+
exports.CAPTCHA_ENDPOINTS = Object.freeze([
|
|
18
|
+
'login',
|
|
19
|
+
'forgot_password',
|
|
20
|
+
'reset_password',
|
|
21
|
+
]);
|
|
22
|
+
exports.CAPTCHA_MODES = Object.freeze(['adaptive', 'always']);
|
|
23
|
+
exports.CAPTCHA_FAIL_MODES = Object.freeze(['closed', 'open']);
|
|
24
|
+
exports.DEFAULT_CAPTCHA_THRESHOLD = 3;
|
|
25
|
+
exports.MAX_CAPTCHA_THRESHOLD = 20;
|
|
26
|
+
exports.DEFAULT_CAPTCHA_MIN_SCORE = 0.5;
|
|
27
|
+
exports.DEFAULT_CAPTCHA_TIMEOUT_MS = 5_000;
|
|
28
|
+
exports.MAX_CAPTCHA_TIMEOUT_MS = 10_000;
|
|
29
|
+
const HOST_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
30
|
+
const HOSTNAME = new RegExp(`^${HOST_LABEL}(?:\\.${HOST_LABEL})*$`);
|
|
31
|
+
const PLAIN_POSITIVE_INT = /^[1-9]\d*$/;
|
|
32
|
+
const DECIMAL = /^(?:0(?:\.\d+)?|1(?:\.0+)?|\.\d+)$/;
|
|
33
|
+
const SITE_KEY = /^[A-Za-z0-9_.-]{1,256}$/;
|
|
34
|
+
function present(v) {
|
|
35
|
+
return typeof v === 'string' && v.trim() !== '';
|
|
36
|
+
}
|
|
37
|
+
/** Read the CAPTCHA config for `captcha: 'env'` (see the file header). */
|
|
38
|
+
function loadCaptchaConfig(env, options = {}) {
|
|
39
|
+
const devOrTest = (0, app_config_js_1.isDevOrTestNodeEnv)(env.NODE_ENV);
|
|
40
|
+
const providerRaw = env.CAPTCHA_PROVIDER?.trim();
|
|
41
|
+
const providers = core_1.CAPTCHA_PROVIDERS.join(', ');
|
|
42
|
+
if (!providerRaw) {
|
|
43
|
+
throw new Error(`CAPTCHA_PROVIDER must be set when ShieldModule.forRoot({ captcha: 'env' }) (one of ${providers})`);
|
|
44
|
+
}
|
|
45
|
+
if (!core_1.CAPTCHA_PROVIDERS.includes(providerRaw)) {
|
|
46
|
+
throw new Error(`CAPTCHA_PROVIDER must be one of ${providers}`);
|
|
47
|
+
}
|
|
48
|
+
const provider = providerRaw;
|
|
49
|
+
const siteKey = env.CAPTCHA_SITE_KEY?.trim() ?? '';
|
|
50
|
+
if (!SITE_KEY.test(siteKey)) {
|
|
51
|
+
throw new Error('CAPTCHA_SITE_KEY must be set (letters, digits, `_`, `.`, `-`; ≤ 256)');
|
|
52
|
+
}
|
|
53
|
+
const secret = readSecret(env, devOrTest, options);
|
|
54
|
+
const expectedHostnames = readHostnames(env, devOrTest);
|
|
55
|
+
return {
|
|
56
|
+
provider,
|
|
57
|
+
siteKey,
|
|
58
|
+
secret,
|
|
59
|
+
expectedHostnames,
|
|
60
|
+
mode: readEnum(env, 'CAPTCHA_MODE', exports.CAPTCHA_MODES, 'adaptive'),
|
|
61
|
+
threshold: readThreshold(env.CAPTCHA_THRESHOLD),
|
|
62
|
+
minScore: readMinScore(env.CAPTCHA_MIN_SCORE),
|
|
63
|
+
failMode: readEnum(env, 'CAPTCHA_FAIL_MODE', exports.CAPTCHA_FAIL_MODES, 'closed'),
|
|
64
|
+
timeoutMs: readTimeout(env.CAPTCHA_TIMEOUT_MS),
|
|
65
|
+
endpoints: readEndpoints(env.CAPTCHA_ENDPOINTS),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function readSecret(env, devOrTest, options) {
|
|
69
|
+
const name = 'CAPTCHA_SECRET';
|
|
70
|
+
const value = env[name];
|
|
71
|
+
if (!present(value))
|
|
72
|
+
throw new Error(`${name} must be set when CAPTCHA_PROVIDER is set`);
|
|
73
|
+
const noProvider = !present(env.SECRETS_PROVIDER) || env.SECRETS_PROVIDER.trim() === 'none';
|
|
74
|
+
if ((0, core_1.isSecretRef)(value)) {
|
|
75
|
+
if (noProvider) {
|
|
76
|
+
throw new Error(`${name} is a secret-ref: value but SECRETS_PROVIDER is not set; configure a secrets ` +
|
|
77
|
+
"provider (e.g. 'encrypted-file') before using captcha: 'env' (runbook §6)");
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`${name} still holds an unresolved secret-ref: value; call resolveBootSecrets() in main.ts ` +
|
|
80
|
+
'before loading AppModule');
|
|
81
|
+
}
|
|
82
|
+
if (!devOrTest && !(options.resolvedFromSecretRef?.(name, env) === true)) {
|
|
83
|
+
const prerequisite = noProvider
|
|
84
|
+
? "; this needs SECRETS_PROVIDER (e.g. 'encrypted-file') configured first (runbook §6)"
|
|
85
|
+
: '';
|
|
86
|
+
throw new Error(`${name} must be a secret-ref: value outside NODE_ENV development/test ` +
|
|
87
|
+
`(literal credentials are refused; see ADR-0032)${prerequisite}`);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function readHostnames(env, devOrTest) {
|
|
92
|
+
const name = 'CAPTCHA_EXPECTED_HOSTNAMES';
|
|
93
|
+
const raw = env[name];
|
|
94
|
+
const list = present(raw)
|
|
95
|
+
? raw
|
|
96
|
+
.split(',')
|
|
97
|
+
.map((h) => h.trim().toLowerCase())
|
|
98
|
+
.filter((h) => h !== '')
|
|
99
|
+
: [];
|
|
100
|
+
if (list.length === 0) {
|
|
101
|
+
if (devOrTest)
|
|
102
|
+
return [];
|
|
103
|
+
throw new Error(`${name} must be set outside NODE_ENV development/test (comma-separated hostnames the ` +
|
|
104
|
+
'CAPTCHA provider may report)');
|
|
105
|
+
}
|
|
106
|
+
if (list.some((h) => h.length > 253 || !HOSTNAME.test(h))) {
|
|
107
|
+
throw new Error(`${name} must be a comma-separated list of hostnames (no scheme, port or path)`);
|
|
108
|
+
}
|
|
109
|
+
return [...new Set(list)];
|
|
110
|
+
}
|
|
111
|
+
function readEnum(env, name, values, fallback) {
|
|
112
|
+
const raw = env[name];
|
|
113
|
+
if (!present(raw))
|
|
114
|
+
return fallback;
|
|
115
|
+
const v = raw.trim();
|
|
116
|
+
if (!values.includes(v)) {
|
|
117
|
+
throw new Error(`${name} must be one of ${values.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
return v;
|
|
120
|
+
}
|
|
121
|
+
function readThreshold(raw) {
|
|
122
|
+
if (!present(raw))
|
|
123
|
+
return exports.DEFAULT_CAPTCHA_THRESHOLD;
|
|
124
|
+
const v = raw.trim();
|
|
125
|
+
const n = PLAIN_POSITIVE_INT.test(v) ? Number(v) : NaN;
|
|
126
|
+
if (!Number.isInteger(n) || n < 1 || n > exports.MAX_CAPTCHA_THRESHOLD) {
|
|
127
|
+
throw new Error(`CAPTCHA_THRESHOLD must be an integer from 1 to ${exports.MAX_CAPTCHA_THRESHOLD}`);
|
|
128
|
+
}
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
function readMinScore(raw) {
|
|
132
|
+
if (!present(raw))
|
|
133
|
+
return exports.DEFAULT_CAPTCHA_MIN_SCORE;
|
|
134
|
+
const v = raw.trim();
|
|
135
|
+
const n = DECIMAL.test(v) ? Number(v) : NaN;
|
|
136
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
137
|
+
throw new Error('CAPTCHA_MIN_SCORE must be a number from 0 to 1');
|
|
138
|
+
}
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
function readTimeout(raw) {
|
|
142
|
+
if (!present(raw))
|
|
143
|
+
return exports.DEFAULT_CAPTCHA_TIMEOUT_MS;
|
|
144
|
+
const v = raw.trim();
|
|
145
|
+
const n = PLAIN_POSITIVE_INT.test(v) ? Number(v) : NaN;
|
|
146
|
+
if (!Number.isInteger(n) || n < 1 || n > exports.MAX_CAPTCHA_TIMEOUT_MS) {
|
|
147
|
+
throw new Error(`CAPTCHA_TIMEOUT_MS must be an integer from 1 to ${exports.MAX_CAPTCHA_TIMEOUT_MS} (milliseconds)`);
|
|
148
|
+
}
|
|
149
|
+
return n;
|
|
150
|
+
}
|
|
151
|
+
function readEndpoints(raw) {
|
|
152
|
+
if (!present(raw))
|
|
153
|
+
return [...exports.CAPTCHA_ENDPOINTS];
|
|
154
|
+
const list = raw
|
|
155
|
+
.split(',')
|
|
156
|
+
.map((e) => e.trim())
|
|
157
|
+
.filter((e) => e !== '');
|
|
158
|
+
if (list.length === 0 ||
|
|
159
|
+
list.some((e) => !exports.CAPTCHA_ENDPOINTS.includes(e))) {
|
|
160
|
+
throw new Error(`CAPTCHA_ENDPOINTS must be a comma-separated list of ${exports.CAPTCHA_ENDPOINTS.join(', ')}`);
|
|
161
|
+
}
|
|
162
|
+
return exports.CAPTCHA_ENDPOINTS.filter((e) => list.includes(e));
|
|
163
|
+
}
|
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
* the runner's operation binding. It must never be a platform root, so `rootIdsFromEnv` refuses it.
|
|
6
6
|
*/
|
|
7
7
|
export declare const ORG_BOOTSTRAP_PRINCIPAL_ID = "5b0d7e1a-0c4f-4a6e-9d3b-b007572a9001";
|
|
8
|
+
/** A framework operation that runs as a reserved principal: its key and its display name. */
|
|
9
|
+
export interface ReservedOperationPrincipal {
|
|
10
|
+
/** The operation key bound to the principal (e.g. `shield:org-bootstrap`). */
|
|
11
|
+
readonly operationKey: string;
|
|
12
|
+
/** The human name shown wherever the principal would otherwise be resolved (the audit viewer). */
|
|
13
|
+
readonly label: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every reserved operation principal, keyed by its lower-case id (KI #140). The audit viewer labels
|
|
17
|
+
* a row by this registry instead of looking the id up as a user (which would read "Deleted user"),
|
|
18
|
+
* and {@link assertNoReservedRootId} refuses every id listed here. Append only.
|
|
19
|
+
*/
|
|
20
|
+
export declare const RESERVED_OPERATION_PRINCIPALS: ReadonlyMap<string, ReservedOperationPrincipal>;
|
|
21
|
+
/** The reserved operation an id belongs to (case-insensitive), else `undefined`. */
|
|
22
|
+
export declare function reservedOperationPrincipal(id: string | null | undefined): ReservedOperationPrincipal | undefined;
|
|
8
23
|
/**
|
|
9
24
|
* Refuse a root allowlist that names a reserved principal, however the app built it (`rootIdsFromEnv`,
|
|
10
25
|
* its own variable, or a literal). Called by `ShieldModule.forRoot` and `IdentityFeatureModule.forRoot`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reserved-principal-ids.d.ts","sourceRoot":"","sources":["../../src/config/reserved-principal-ids.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,yCAAyC,CAAC;AAEjF;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"reserved-principal-ids.d.ts","sourceRoot":"","sources":["../../src/config/reserved-principal-ids.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,yCAAyC,CAAC;AAEjF,6FAA6F;AAC7F,MAAM,WAAW,0BAA0B;IACzC,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,kGAAkG;IAClG,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,EAAE,WAAW,CAAC,MAAM,EAAE,0BAA0B,CAMtF,CAAC;AAEL,oFAAoF;AACpF,wBAAgB,0BAA0B,CACxC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC5B,0BAA0B,GAAG,SAAS,CAGxC;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAK1F"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ORG_BOOTSTRAP_PRINCIPAL_ID = void 0;
|
|
3
|
+
exports.RESERVED_OPERATION_PRINCIPALS = exports.ORG_BOOTSTRAP_PRINCIPAL_ID = void 0;
|
|
4
|
+
exports.reservedOperationPrincipal = reservedOperationPrincipal;
|
|
4
5
|
exports.assertNoReservedRootId = assertNoReservedRootId;
|
|
5
6
|
/**
|
|
6
7
|
* Principal ids the framework reserves for its own trusted operations (Shield #9).
|
|
@@ -9,6 +10,23 @@ exports.assertNoReservedRootId = assertNoReservedRootId;
|
|
|
9
10
|
* the runner's operation binding. It must never be a platform root, so `rootIdsFromEnv` refuses it.
|
|
10
11
|
*/
|
|
11
12
|
exports.ORG_BOOTSTRAP_PRINCIPAL_ID = '5b0d7e1a-0c4f-4a6e-9d3b-b007572a9001';
|
|
13
|
+
/**
|
|
14
|
+
* Every reserved operation principal, keyed by its lower-case id (KI #140). The audit viewer labels
|
|
15
|
+
* a row by this registry instead of looking the id up as a user (which would read "Deleted user"),
|
|
16
|
+
* and {@link assertNoReservedRootId} refuses every id listed here. Append only.
|
|
17
|
+
*/
|
|
18
|
+
exports.RESERVED_OPERATION_PRINCIPALS = new Map([
|
|
19
|
+
[
|
|
20
|
+
exports.ORG_BOOTSTRAP_PRINCIPAL_ID.toLowerCase(),
|
|
21
|
+
Object.freeze({ operationKey: 'shield:org-bootstrap', label: 'Org bootstrap' }),
|
|
22
|
+
],
|
|
23
|
+
]);
|
|
24
|
+
/** The reserved operation an id belongs to (case-insensitive), else `undefined`. */
|
|
25
|
+
function reservedOperationPrincipal(id) {
|
|
26
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
return exports.RESERVED_OPERATION_PRINCIPALS.get(id.trim().toLowerCase());
|
|
29
|
+
}
|
|
12
30
|
/**
|
|
13
31
|
* Refuse a root allowlist that names a reserved principal, however the app built it (`rootIdsFromEnv`,
|
|
14
32
|
* its own variable, or a literal). Called by `ShieldModule.forRoot` and `IdentityFeatureModule.forRoot`.
|
|
@@ -17,8 +35,7 @@ exports.ORG_BOOTSTRAP_PRINCIPAL_ID = '5b0d7e1a-0c4f-4a6e-9d3b-b007572a9001';
|
|
|
17
35
|
function assertNoReservedRootId(rootIds) {
|
|
18
36
|
if (!rootIds)
|
|
19
37
|
return;
|
|
20
|
-
|
|
21
|
-
if (rootIds.some((id) => typeof id === 'string' && id.trim().toLowerCase() === reserved)) {
|
|
38
|
+
if (rootIds.some((id) => reservedOperationPrincipal(id) !== undefined)) {
|
|
22
39
|
throw new Error('rootIds contains a reserved principal id; a reserved id is never a root.');
|
|
23
40
|
}
|
|
24
41
|
}
|