@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,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CaptchaGate = exports.RedisCaptchaCounterStore = exports.CAPTCHA_BUMP_SCRIPT = exports.AUTH_CAPTCHA_FAIL_OPEN = exports.AUTH_CAPTCHA_FAILED = exports.CAPTCHA_WINDOW_MS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The CAPTCHA gate on `/auth/login`, `/auth/forgot-password` and `/auth/reset-password`
|
|
6
|
+
* (auth capabilities R7.2–R7.5, X2, X3; AC-12, AC-13).
|
|
7
|
+
*
|
|
8
|
+
* ── WHEN A CHALLENGE IS REQUIRED ────────────────────────────────────────────
|
|
9
|
+
* `always` ⇒ every request to a covered endpoint. `adaptive` ⇒ once ANY of these per-endpoint
|
|
10
|
+
* counters reaches `CAPTCHA_THRESHOLD`:
|
|
11
|
+
* `captcha:<endpoint>:pid:<principalId>` the resolved account (every spelling shares it);
|
|
12
|
+
* `captcha:<endpoint>:txt:<sha256(canonical typed text)>` the typed hint, bumped for known
|
|
13
|
+
* AND unknown names alike, so the same typed text reaches the threshold at the same attempt
|
|
14
|
+
* whether or not an account exists (X2);
|
|
15
|
+
* `captcha:<endpoint>:ip:<ip>` only when per-IP limiting is on (explicit `trust proxy`),
|
|
16
|
+
* like every other per-IP limiter (otherwise every client shares the proxy's IP).
|
|
17
|
+
* Keys live under `shield:captcha:` with a fixed 15-minute window from the first hit; no
|
|
18
|
+
* personal data in a key. Login bumps on a failed password; forgot-password on every request
|
|
19
|
+
* (a request-rate cap: it has no failure signal); reset-password on a bad reset token (IP only:
|
|
20
|
+
* no account is known before the token is consumed). A success clears the account and
|
|
21
|
+
* typed-text keys, never the IP key.
|
|
22
|
+
*
|
|
23
|
+
* ── REDIS ── one MGET to decide, one EVAL to bump. A Redis error while deciding REQUIRES the
|
|
24
|
+
* challenge (fail closed, Shield #1): the attempt then needs a valid token, and the failure
|
|
25
|
+
* response carries `captcha: 'required'`. A bump error is treated the same way for the body flag.
|
|
26
|
+
*
|
|
27
|
+
* ── VERIFYING ── the token is checked BEFORE any password hash, code check or reset-token use
|
|
28
|
+
* (R7.4). A missing token never calls the provider. Outcomes:
|
|
29
|
+
* passed / not required ⇒ proceed;
|
|
30
|
+
* rejected ⇒ fail (counter `auth.captcha.failed`; audit row `auth.captcha.failed` for a
|
|
31
|
+
* RESOLVED account, at most one per account, endpoint and window — R12.3 bounding);
|
|
32
|
+
* provider unavailable ⇒ `CAPTCHA_FAIL_MODE`: `closed` (default) is a failure with reason
|
|
33
|
+
* `unavailable`; `open` proceeds, counted `auth.captcha.fail_open` every time and audited
|
|
34
|
+
* `auth.captcha.fail_open` when the account resolved (the default sink skips actor-less
|
|
35
|
+
* rows, so the counter is the record for unknown names). Lockout and limits still apply.
|
|
36
|
+
*
|
|
37
|
+
* Counters are one structured log line each (R12.4 style): fixed keys, no hint, no token.
|
|
38
|
+
*/
|
|
39
|
+
const common_1 = require("@nestjs/common");
|
|
40
|
+
const core_1 = require("@astram/core");
|
|
41
|
+
const auth_audit_js_1 = require("./auth-audit.js");
|
|
42
|
+
const canonical_hint_js_1 = require("./rate-limit/canonical-hint.js");
|
|
43
|
+
/** Fixed counter window (ms). */
|
|
44
|
+
exports.CAPTCHA_WINDOW_MS = 15 * 60 * 1000;
|
|
45
|
+
exports.AUTH_CAPTCHA_FAILED = 'auth.captcha.failed';
|
|
46
|
+
exports.AUTH_CAPTCHA_FAIL_OPEN = 'auth.captcha.fail_open';
|
|
47
|
+
/**
|
|
48
|
+
* Bump every key and arm the window on the first hit (or a key left without a TTL). Returns the
|
|
49
|
+
* new counts, in key order. KEYS = the counter keys; ARGV[1] = window ms.
|
|
50
|
+
*/
|
|
51
|
+
exports.CAPTCHA_BUMP_SCRIPT = `local out = {}
|
|
52
|
+
for i, k in ipairs(KEYS) do
|
|
53
|
+
local n = redis.call('INCR', k)
|
|
54
|
+
if n == 1 or redis.call('PTTL', k) == -1 then
|
|
55
|
+
redis.call('PEXPIRE', k, ARGV[1])
|
|
56
|
+
end
|
|
57
|
+
out[i] = n
|
|
58
|
+
end
|
|
59
|
+
return out`;
|
|
60
|
+
/** {@link CaptchaCounterStore} over the guard's bounded ioredis client. */
|
|
61
|
+
class RedisCaptchaCounterStore {
|
|
62
|
+
redis;
|
|
63
|
+
windowMs;
|
|
64
|
+
constructor(redis, windowMs = exports.CAPTCHA_WINDOW_MS) {
|
|
65
|
+
this.redis = redis;
|
|
66
|
+
this.windowMs = windowMs;
|
|
67
|
+
}
|
|
68
|
+
k(key) {
|
|
69
|
+
return `shield:captcha:${key}`;
|
|
70
|
+
}
|
|
71
|
+
async counts(keys) {
|
|
72
|
+
if (keys.length === 0)
|
|
73
|
+
return [];
|
|
74
|
+
const raw = await this.redis.mget(...keys.map((k) => this.k(k)));
|
|
75
|
+
return raw.map((v) => {
|
|
76
|
+
const n = v === null ? 0 : Number(v);
|
|
77
|
+
return Number.isFinite(n) ? n : 0;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async bump(keys) {
|
|
81
|
+
if (keys.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
const out = (await this.redis.eval(exports.CAPTCHA_BUMP_SCRIPT, keys.length, ...keys.map((k) => this.k(k)), String(this.windowMs)));
|
|
84
|
+
if (!Array.isArray(out))
|
|
85
|
+
throw new Error('captcha counter: unexpected reply');
|
|
86
|
+
return out.map((n) => Number(n));
|
|
87
|
+
}
|
|
88
|
+
async clear(keys) {
|
|
89
|
+
if (keys.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
await this.redis.del(...keys.map((k) => this.k(k)));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Best-effort: the window expires the keys anyway.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Remove keys and REJECT on a store error (auth capabilities R11.1: an admin unlock clears the
|
|
100
|
+
* account counters and must not report a success it did not achieve). Not on the port: only the
|
|
101
|
+
* unlocker uses it.
|
|
102
|
+
*/
|
|
103
|
+
async clearStrict(keys) {
|
|
104
|
+
if (keys.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
await this.redis.del(...keys.map((k) => this.k(k)));
|
|
107
|
+
}
|
|
108
|
+
async firstInWindow(key) {
|
|
109
|
+
try {
|
|
110
|
+
return (await this.redis.set(this.k(key), '1', 'PX', this.windowMs, 'NX')) === 'OK';
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
exports.RedisCaptchaCounterStore = RedisCaptchaCounterStore;
|
|
118
|
+
class CaptchaGate {
|
|
119
|
+
deps;
|
|
120
|
+
logger;
|
|
121
|
+
constructor(deps) {
|
|
122
|
+
this.deps = deps;
|
|
123
|
+
this.logger = deps.logger ?? new common_1.Logger('ShieldCaptcha');
|
|
124
|
+
}
|
|
125
|
+
/** The public part of the config (provider, site key, mode). */
|
|
126
|
+
get publicOptions() {
|
|
127
|
+
const { provider, siteKey, mode } = this.deps.config;
|
|
128
|
+
return { provider, siteKey, mode };
|
|
129
|
+
}
|
|
130
|
+
/** True when this endpoint is protected (`CAPTCHA_ENDPOINTS`). */
|
|
131
|
+
covers(endpoint) {
|
|
132
|
+
return this.deps.config.endpoints.includes(endpoint);
|
|
133
|
+
}
|
|
134
|
+
/** True when the mode is `always` (every attempt is challenged). */
|
|
135
|
+
get always() {
|
|
136
|
+
return this.deps.config.mode === 'always';
|
|
137
|
+
}
|
|
138
|
+
keys(endpoint, s) {
|
|
139
|
+
const keys = [];
|
|
140
|
+
if (s.accountId)
|
|
141
|
+
keys.push(`${endpoint}:pid:${s.accountId}`);
|
|
142
|
+
if (typeof s.typedText === 'string' && s.typedText !== '') {
|
|
143
|
+
keys.push(`${endpoint}:txt:${(0, canonical_hint_js_1.hintKeyDigest)(s.typedText)}`);
|
|
144
|
+
}
|
|
145
|
+
if (s.ip)
|
|
146
|
+
keys.push(`${endpoint}:ip:${s.ip}`);
|
|
147
|
+
return keys;
|
|
148
|
+
}
|
|
149
|
+
overThreshold(counts) {
|
|
150
|
+
return counts.some((n) => n >= this.deps.config.threshold);
|
|
151
|
+
}
|
|
152
|
+
/** Is a challenge required now? A store error ⇒ `true` (fail closed). */
|
|
153
|
+
async required(endpoint, subject) {
|
|
154
|
+
if (!this.covers(endpoint))
|
|
155
|
+
return false;
|
|
156
|
+
if (this.always)
|
|
157
|
+
return true;
|
|
158
|
+
const keys = this.keys(endpoint, subject);
|
|
159
|
+
if (keys.length === 0)
|
|
160
|
+
return false;
|
|
161
|
+
try {
|
|
162
|
+
return this.overThreshold(await this.deps.counters.counts(keys));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Decide and, when required, verify `token`. Call BEFORE any hash / code check. Never throws.
|
|
170
|
+
*/
|
|
171
|
+
async check(endpoint, subject, token, remoteIp) {
|
|
172
|
+
if (!(await this.required(endpoint, subject)))
|
|
173
|
+
return { kind: 'not_required' };
|
|
174
|
+
const account = subject.accountId ? 'known' : 'unknown';
|
|
175
|
+
if (typeof token !== 'string' || token.trim() === '') {
|
|
176
|
+
this.count(exports.AUTH_CAPTCHA_FAILED, endpoint, account, 'missing');
|
|
177
|
+
return { kind: 'failed' };
|
|
178
|
+
}
|
|
179
|
+
let result;
|
|
180
|
+
try {
|
|
181
|
+
result = await this.deps.verifier.verify({
|
|
182
|
+
token,
|
|
183
|
+
expectedAction: endpoint,
|
|
184
|
+
...(remoteIp ? { remoteIp } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
result = { ok: false, reason: 'unavailable' };
|
|
189
|
+
}
|
|
190
|
+
if (result.ok)
|
|
191
|
+
return { kind: 'passed' };
|
|
192
|
+
if ((0, core_1.isCaptchaUnavailable)(result) && this.deps.config.failMode === 'open') {
|
|
193
|
+
this.count(exports.AUTH_CAPTCHA_FAIL_OPEN, endpoint, account);
|
|
194
|
+
if (subject.accountId && subject.tenantId) {
|
|
195
|
+
(0, auth_audit_js_1.emitAuthAudit)(this.deps.auditSink, (0, auth_audit_js_1.authEvent)(exports.AUTH_CAPTCHA_FAIL_OPEN, subject.accountId, subject.tenantId, { endpoint }));
|
|
196
|
+
}
|
|
197
|
+
return { kind: 'fail_open' };
|
|
198
|
+
}
|
|
199
|
+
this.count(exports.AUTH_CAPTCHA_FAILED, endpoint, account, result.reason);
|
|
200
|
+
if (subject.accountId && subject.tenantId) {
|
|
201
|
+
const accountId = subject.accountId;
|
|
202
|
+
const tenantId = subject.tenantId;
|
|
203
|
+
const reason = result.reason;
|
|
204
|
+
void this.deps.counters
|
|
205
|
+
.firstInWindow(`audit:${endpoint}:pid:${accountId}`)
|
|
206
|
+
.then((first) => {
|
|
207
|
+
if (first) {
|
|
208
|
+
(0, auth_audit_js_1.emitAuthAudit)(this.deps.auditSink, (0, auth_audit_js_1.authEvent)(exports.AUTH_CAPTCHA_FAILED, accountId, tenantId, { endpoint, reason }));
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
.catch(() => undefined);
|
|
212
|
+
}
|
|
213
|
+
return { kind: 'failed' };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Count one failure (login: wrong password; forgot: every request; reset: bad token). Returns
|
|
217
|
+
* whether the NEXT attempt needs a challenge (the failure body's `captcha: 'required'` flag). A
|
|
218
|
+
* store error ⇒ `true` (fail closed, consistent with {@link required}).
|
|
219
|
+
*/
|
|
220
|
+
async recordFailure(endpoint, subject) {
|
|
221
|
+
if (!this.covers(endpoint))
|
|
222
|
+
return false;
|
|
223
|
+
if (this.always)
|
|
224
|
+
return true;
|
|
225
|
+
const keys = this.keys(endpoint, subject);
|
|
226
|
+
let counts = [];
|
|
227
|
+
try {
|
|
228
|
+
counts = await this.deps.counters.bump(keys);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
return this.overThreshold(counts);
|
|
234
|
+
}
|
|
235
|
+
/** A success clears the account and typed-text counters (never the IP one). Best-effort. */
|
|
236
|
+
async recordSuccess(endpoint, subject) {
|
|
237
|
+
if (!this.covers(endpoint))
|
|
238
|
+
return;
|
|
239
|
+
await this.deps.counters.clear(this.keys(endpoint, { ...subject, ip: null }));
|
|
240
|
+
}
|
|
241
|
+
count(counter, endpoint, account, reason) {
|
|
242
|
+
this.logger.log({ counter, endpoint, account, ...(reason ? { reason } : {}) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
exports.CaptchaGate = CaptchaGate;
|
package/dist/auth/dto.d.ts
CHANGED
|
@@ -102,7 +102,22 @@ export interface LoginRequest {
|
|
|
102
102
|
/** Username or email — resolved to a principal by the credential resolver. */
|
|
103
103
|
readonly identityHint: string;
|
|
104
104
|
/** ⛔ The plaintext password — verified then discarded; NEVER logged/echoed. */
|
|
105
|
-
readonly password: string;
|
|
105
|
+
readonly password: string; /**
|
|
106
|
+
* Auth capabilities R7: the CAPTCHA widget's response token, required once the server answers
|
|
107
|
+
* `captcha: 'required'` (or always, in `always` mode). Verified with the provider, never logged.
|
|
108
|
+
*/
|
|
109
|
+
readonly captchaToken?: string;
|
|
110
|
+
}
|
|
111
|
+
/** `GET /auth/login-options` (auth capabilities R7.3, R13.2): public sign-in page settings. */
|
|
112
|
+
export interface LoginOptionsView {
|
|
113
|
+
/** Accepted sign-in forms (`['email']` or `['email', 'username']`). */
|
|
114
|
+
readonly signInWith: readonly ('email' | 'username')[];
|
|
115
|
+
/** Public CAPTCHA settings, or `null` when CAPTCHA is off. Never the secret. */
|
|
116
|
+
readonly captcha: {
|
|
117
|
+
readonly provider: 'turnstile' | 'recaptcha-v3' | 'recaptcha-v2';
|
|
118
|
+
readonly siteKey: string;
|
|
119
|
+
readonly mode: 'adaptive' | 'always';
|
|
120
|
+
} | null;
|
|
106
121
|
}
|
|
107
122
|
/**
|
|
108
123
|
* The MFA-challenge branch of a login/step-up response: the password was correct
|
|
@@ -174,6 +189,12 @@ export type RefreshResponse = SessionSuccess;
|
|
|
174
189
|
*/
|
|
175
190
|
export interface LogoutRequest {
|
|
176
191
|
readonly sessionId: string;
|
|
192
|
+
/**
|
|
193
|
+
* OPTIONAL proof of ownership (R15.1): a refresh token of the named session. An
|
|
194
|
+
* `Authorization: Bearer <access token>` bound to the session is the other accepted proof.
|
|
195
|
+
* Without either, the session is revoked only while `logoutRequiresProof` is `false`.
|
|
196
|
+
*/
|
|
197
|
+
readonly refreshToken?: string;
|
|
177
198
|
}
|
|
178
199
|
/**
|
|
179
200
|
* `POST /auth/logout` acknowledgement. The endpoint returns HTTP 204 (no body) on
|
package/dist/auth/dto.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dto.d.ts","sourceRoot":"","sources":["../../src/auth/dto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;IAC9D,oBAAoB;IACpB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,oBAAoB;IACpB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;QAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;KAC9B,CAAC,CAAC;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACtC;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC;;;;;;;;;OASG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,aAAa,CAAC;CACpC;AAMD,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"dto.d.ts","sourceRoot":"","sources":["../../src/auth/dto.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth capabilities R1.9: when `signInWith` includes `'username'`, log once at boot how many
|
|
3
|
+
* active users have a login name (`users.u_login_name`). Count only, no names. Read through the
|
|
4
|
+
* guard's runtime pool. A missing table (errno 1146) or column (1054, migration 103 not applied)
|
|
5
|
+
* skips with one warning; any other error warns without detail. Never blocks boot.
|
|
6
|
+
*/
|
|
7
|
+
import { type OnApplicationBootstrap } from '@nestjs/common';
|
|
8
|
+
import type { SignInMethod } from '../authentication/sign-in-with.js';
|
|
9
|
+
export type CountQuery = (sql: string) => Promise<unknown[]>;
|
|
10
|
+
export declare class LoginNameCountBoot implements OnApplicationBootstrap {
|
|
11
|
+
private readonly query;
|
|
12
|
+
/** Already-qualified `<app>`.`users` (from `qualifiedTableName`). */
|
|
13
|
+
private readonly usersTable;
|
|
14
|
+
private readonly signInWith;
|
|
15
|
+
private readonly logger;
|
|
16
|
+
constructor(query: CountQuery,
|
|
17
|
+
/** Already-qualified `<app>`.`users` (from `qualifiedTableName`). */
|
|
18
|
+
usersTable: string, signInWith: readonly SignInMethod[], logger?: {
|
|
19
|
+
log(message: string): void;
|
|
20
|
+
warn(message: string): void;
|
|
21
|
+
});
|
|
22
|
+
onApplicationBootstrap(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=login-name-count-boot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login-name-count-boot.d.ts","sourceRoot":"","sources":["../../src/auth/login-name-count-boot.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAU,KAAK,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAErE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AAKtE,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;AAE7D,qBAAa,kBAAmB,YAAW,sBAAsB;IAI7D,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,qEAAqE;IACrE,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA8D;gBAGlE,KAAK,EAAE,UAAU;IAClC,qEAAqE;IACpD,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,SAAS,YAAY,EAAE,EACpD,MAAM,CAAC,EAAE;QAAE,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;IAKhE,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;CAsB9C"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LoginNameCountBoot = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Auth capabilities R1.9: when `signInWith` includes `'username'`, log once at boot how many
|
|
6
|
+
* active users have a login name (`users.u_login_name`). Count only, no names. Read through the
|
|
7
|
+
* guard's runtime pool. A missing table (errno 1146) or column (1054, migration 103 not applied)
|
|
8
|
+
* skips with one warning; any other error warns without detail. Never blocks boot.
|
|
9
|
+
*/
|
|
10
|
+
const common_1 = require("@nestjs/common");
|
|
11
|
+
const ER_BAD_FIELD_ERROR = 1054;
|
|
12
|
+
const ER_NO_SUCH_TABLE = 1146;
|
|
13
|
+
class LoginNameCountBoot {
|
|
14
|
+
query;
|
|
15
|
+
usersTable;
|
|
16
|
+
signInWith;
|
|
17
|
+
logger;
|
|
18
|
+
constructor(query,
|
|
19
|
+
/** Already-qualified `<app>`.`users` (from `qualifiedTableName`). */
|
|
20
|
+
usersTable, signInWith, logger) {
|
|
21
|
+
this.query = query;
|
|
22
|
+
this.usersTable = usersTable;
|
|
23
|
+
this.signInWith = signInWith;
|
|
24
|
+
this.logger = logger ?? new common_1.Logger('ShieldAuth');
|
|
25
|
+
}
|
|
26
|
+
async onApplicationBootstrap() {
|
|
27
|
+
if (!this.signInWith.includes('username'))
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const rows = await this.query(`SELECT COUNT(*) AS n FROM ${this.usersTable} ` +
|
|
31
|
+
`WHERE u_login_name IS NOT NULL AND u_status = 'active'`);
|
|
32
|
+
const raw = rows[0]?.n;
|
|
33
|
+
const n = typeof raw === 'bigint' ? raw.toString() : String(Number(raw ?? 0));
|
|
34
|
+
this.logger.log(`signInWith includes username: ${n} active users have a login name`);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const errno = err?.errno;
|
|
38
|
+
if (errno === ER_NO_SUCH_TABLE || errno === ER_BAD_FIELD_ERROR) {
|
|
39
|
+
this.logger.warn('signInWith includes username but users.u_login_name is missing (apply migration 103); ' +
|
|
40
|
+
'login-name count skipped');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.logger.warn('signInWith includes username: could not count users with a login name');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.LoginNameCountBoot = LoginNameCountBoot;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logout proof (R15.1, D-9, AC-19). `POST /auth/logout` is `@Public()`, so the session it ends
|
|
3
|
+
* must be proven by the caller, not just named:
|
|
4
|
+
*
|
|
5
|
+
* - a bearer access token whose bound claims carry the named `sessionId`, OR
|
|
6
|
+
* - a refresh token whose keyed hash (`SHIELD_REFRESH_HASHER`) finds a refresh record of that
|
|
7
|
+
* session (`SessionRecordStore.findRefreshByHash`). Any record status counts: replaying a
|
|
8
|
+
* rotated token already kills the whole lineage through `/auth/refresh`, so accepting it here
|
|
9
|
+
* gives a holder no new power, and it keeps logout working across a refresh race.
|
|
10
|
+
*
|
|
11
|
+
* Outcomes:
|
|
12
|
+
* - `proven` → a supplied token proves the named session.
|
|
13
|
+
* - `mismatch` → a token was supplied but proves another session, or is unknown / invalid.
|
|
14
|
+
* - `none` → no bearer and no refresh token were supplied (an old frontend).
|
|
15
|
+
* - `unavailable` → no token proved the session and a lookup failed (Redis or DB error). The
|
|
16
|
+
* controller treats it like `none`: strict mode revokes nothing (fail closed),
|
|
17
|
+
* compatibility mode revokes as before (today's behaviour, counted).
|
|
18
|
+
*
|
|
19
|
+
* Raw tokens are never logged; the unproven counter carries no ids.
|
|
20
|
+
*/
|
|
21
|
+
import { Logger, type Provider } from '@nestjs/common';
|
|
22
|
+
import type { RefreshTokenHasher, SessionRecordStore } from '@astram/core';
|
|
23
|
+
import type { SessionTokenBinder } from '../request-auth/session-token-binder.js';
|
|
24
|
+
/** DI token for the {@link LogoutProofVerifier}. */
|
|
25
|
+
export declare const SHIELD_LOGOUT_PROOF_VERIFIER: unique symbol;
|
|
26
|
+
/** DI token for the `logoutRequiresProof` option (boolean; default `false`). */
|
|
27
|
+
export declare const SHIELD_LOGOUT_REQUIRES_PROOF: unique symbol;
|
|
28
|
+
/** The fixed counter key written for every unproven logout that still revokes (R12.4 style). */
|
|
29
|
+
export declare const LOGOUT_UNPROVEN_COUNTER = "auth.logout.unproven";
|
|
30
|
+
export type LogoutProofOutcome = 'proven' | 'mismatch' | 'none' | 'unavailable';
|
|
31
|
+
export declare class LogoutProofVerifier {
|
|
32
|
+
private readonly binder;
|
|
33
|
+
private readonly hasher;
|
|
34
|
+
private readonly records;
|
|
35
|
+
constructor(binder: Pick<SessionTokenBinder, 'resolve'> | null, hasher: RefreshTokenHasher | null, records: Pick<SessionRecordStore, 'findRefreshByHash'> | null);
|
|
36
|
+
/**
|
|
37
|
+
* Classify the proof a logout request carries for `sessionId`. Never throws.
|
|
38
|
+
* @param accessToken the bearer token from `Authorization`, or `null`.
|
|
39
|
+
* @param refreshToken the body's `refreshToken`, or `null`.
|
|
40
|
+
*/
|
|
41
|
+
verify(sessionId: string, accessToken: string | null, refreshToken: string | null): Promise<LogoutProofOutcome>;
|
|
42
|
+
}
|
|
43
|
+
/** The process-local count of unproven logouts that still revoked (compatibility mode). */
|
|
44
|
+
export declare function unprovenLogoutCount(): number;
|
|
45
|
+
/**
|
|
46
|
+
* Count one unproven logout that revoked (compatibility mode): a structured log line with the
|
|
47
|
+
* fixed key {@link LOGOUT_UNPROVEN_COUNTER} and no ids, plus a one-time deprecation warning per
|
|
48
|
+
* process.
|
|
49
|
+
*/
|
|
50
|
+
export declare function recordUnprovenLogout(logger?: Pick<Logger, 'log' | 'warn'>): void;
|
|
51
|
+
/** The verifier over the bound token binder, refresh hasher and durable record store. */
|
|
52
|
+
export declare const logoutProofVerifierProvider: Provider;
|
|
53
|
+
/** Bind the `logoutRequiresProof` option (absent → `false`, the compatibility default). */
|
|
54
|
+
export declare function makeLogoutRequiresProofProvider(requiresProof?: boolean): Provider;
|
|
55
|
+
//# sourceMappingURL=logout-proof.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logout-proof.d.ts","sourceRoot":"","sources":["../../src/auth/logout-proof.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAC3E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAKlF,oDAAoD;AACpD,eAAO,MAAM,4BAA4B,eAAyC,CAAC;AACnF,gFAAgF;AAChF,eAAO,MAAM,4BAA4B,eAAyC,CAAC;AACnF,gGAAgG;AAChG,eAAO,MAAM,uBAAuB,yBAAyB,CAAC;AAE9D,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,UAAU,GAAG,MAAM,GAAG,aAAa,CAAC;AAEhF,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,SAAS,CAAC,GAAG,IAAI,EAClD,MAAM,EAAE,kBAAkB,GAAG,IAAI,EACjC,OAAO,EAAE,IAAI,CAAC,kBAAkB,EAAE,mBAAmB,CAAC,GAAG,IAAI;IAGhF;;;;OAIG;IACG,MAAM,CACV,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,GAC1B,OAAO,CAAC,kBAAkB,CAAC;CAoC/B;AAMD,2FAA2F;AAC3F,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,GAAE,IAAI,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,CAAe,GAAG,IAAI,CAY7F;AAED,yFAAyF;AACzF,eAAO,MAAM,2BAA2B,EAAE,QAazC,CAAC;AAEF,2FAA2F;AAC3F,wBAAgB,+BAA+B,CAAC,aAAa,CAAC,EAAE,OAAO,GAAG,QAAQ,CAEjF"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Logout proof (R15.1, D-9, AC-19). `POST /auth/logout` is `@Public()`, so the session it ends
|
|
4
|
+
* must be proven by the caller, not just named:
|
|
5
|
+
*
|
|
6
|
+
* - a bearer access token whose bound claims carry the named `sessionId`, OR
|
|
7
|
+
* - a refresh token whose keyed hash (`SHIELD_REFRESH_HASHER`) finds a refresh record of that
|
|
8
|
+
* session (`SessionRecordStore.findRefreshByHash`). Any record status counts: replaying a
|
|
9
|
+
* rotated token already kills the whole lineage through `/auth/refresh`, so accepting it here
|
|
10
|
+
* gives a holder no new power, and it keeps logout working across a refresh race.
|
|
11
|
+
*
|
|
12
|
+
* Outcomes:
|
|
13
|
+
* - `proven` → a supplied token proves the named session.
|
|
14
|
+
* - `mismatch` → a token was supplied but proves another session, or is unknown / invalid.
|
|
15
|
+
* - `none` → no bearer and no refresh token were supplied (an old frontend).
|
|
16
|
+
* - `unavailable` → no token proved the session and a lookup failed (Redis or DB error). The
|
|
17
|
+
* controller treats it like `none`: strict mode revokes nothing (fail closed),
|
|
18
|
+
* compatibility mode revokes as before (today's behaviour, counted).
|
|
19
|
+
*
|
|
20
|
+
* Raw tokens are never logged; the unproven counter carries no ids.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.logoutProofVerifierProvider = exports.LogoutProofVerifier = exports.LOGOUT_UNPROVEN_COUNTER = exports.SHIELD_LOGOUT_REQUIRES_PROOF = exports.SHIELD_LOGOUT_PROOF_VERIFIER = void 0;
|
|
24
|
+
exports.unprovenLogoutCount = unprovenLogoutCount;
|
|
25
|
+
exports.recordUnprovenLogout = recordUnprovenLogout;
|
|
26
|
+
exports.makeLogoutRequiresProofProvider = makeLogoutRequiresProofProvider;
|
|
27
|
+
const common_1 = require("@nestjs/common");
|
|
28
|
+
const tokens_js_1 = require("../request-auth/tokens.js");
|
|
29
|
+
const mariadb_session_record_store_js_1 = require("../session/mariadb-session-record-store.js");
|
|
30
|
+
const crypto_adapters_js_1 = require("../session/crypto-adapters.js");
|
|
31
|
+
/** DI token for the {@link LogoutProofVerifier}. */
|
|
32
|
+
exports.SHIELD_LOGOUT_PROOF_VERIFIER = Symbol('SHIELD_LOGOUT_PROOF_VERIFIER');
|
|
33
|
+
/** DI token for the `logoutRequiresProof` option (boolean; default `false`). */
|
|
34
|
+
exports.SHIELD_LOGOUT_REQUIRES_PROOF = Symbol('SHIELD_LOGOUT_REQUIRES_PROOF');
|
|
35
|
+
/** The fixed counter key written for every unproven logout that still revokes (R12.4 style). */
|
|
36
|
+
exports.LOGOUT_UNPROVEN_COUNTER = 'auth.logout.unproven';
|
|
37
|
+
class LogoutProofVerifier {
|
|
38
|
+
binder;
|
|
39
|
+
hasher;
|
|
40
|
+
records;
|
|
41
|
+
constructor(binder, hasher, records) {
|
|
42
|
+
this.binder = binder;
|
|
43
|
+
this.hasher = hasher;
|
|
44
|
+
this.records = records;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Classify the proof a logout request carries for `sessionId`. Never throws.
|
|
48
|
+
* @param accessToken the bearer token from `Authorization`, or `null`.
|
|
49
|
+
* @param refreshToken the body's `refreshToken`, or `null`.
|
|
50
|
+
*/
|
|
51
|
+
async verify(sessionId, accessToken, refreshToken) {
|
|
52
|
+
if (accessToken === null && refreshToken === null)
|
|
53
|
+
return 'none';
|
|
54
|
+
let failed = false;
|
|
55
|
+
if (accessToken !== null) {
|
|
56
|
+
if (this.binder === null) {
|
|
57
|
+
failed = true;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
try {
|
|
61
|
+
const claims = await this.binder.resolve(accessToken);
|
|
62
|
+
if (claims !== null && sessionId !== '' && claims.sessionId === sessionId) {
|
|
63
|
+
return 'proven';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
failed = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (refreshToken !== null) {
|
|
72
|
+
if (this.hasher === null || this.records === null) {
|
|
73
|
+
failed = true;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
try {
|
|
77
|
+
const record = await this.records.findRefreshByHash(this.hasher.hash(refreshToken));
|
|
78
|
+
if (record !== null && sessionId !== '' && record.sessionId === sessionId) {
|
|
79
|
+
return 'proven';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
failed = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return failed ? 'unavailable' : 'mismatch';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.LogoutProofVerifier = LogoutProofVerifier;
|
|
91
|
+
let unprovenCount = 0;
|
|
92
|
+
let deprecationWarned = false;
|
|
93
|
+
const proofLogger = new common_1.Logger('ShieldAuth');
|
|
94
|
+
/** The process-local count of unproven logouts that still revoked (compatibility mode). */
|
|
95
|
+
function unprovenLogoutCount() {
|
|
96
|
+
return unprovenCount;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Count one unproven logout that revoked (compatibility mode): a structured log line with the
|
|
100
|
+
* fixed key {@link LOGOUT_UNPROVEN_COUNTER} and no ids, plus a one-time deprecation warning per
|
|
101
|
+
* process.
|
|
102
|
+
*/
|
|
103
|
+
function recordUnprovenLogout(logger = proofLogger) {
|
|
104
|
+
unprovenCount += 1;
|
|
105
|
+
if (!deprecationWarned) {
|
|
106
|
+
deprecationWarned = true;
|
|
107
|
+
logger.warn('POST /auth/logout without proof (no refresh token, no bearer) still revokes the named ' +
|
|
108
|
+
'session; this is deprecated. Send the refresh token or the access token, then set ' +
|
|
109
|
+
'ShieldModule.forRoot({ logoutRequiresProof: true }). It becomes the default at the next ' +
|
|
110
|
+
'guard major.');
|
|
111
|
+
}
|
|
112
|
+
logger.log({ counter: exports.LOGOUT_UNPROVEN_COUNTER, count: unprovenCount });
|
|
113
|
+
}
|
|
114
|
+
/** The verifier over the bound token binder, refresh hasher and durable record store. */
|
|
115
|
+
exports.logoutProofVerifierProvider = {
|
|
116
|
+
provide: exports.SHIELD_LOGOUT_PROOF_VERIFIER,
|
|
117
|
+
useFactory: (binder, hasher, records) => new LogoutProofVerifier(binder ?? null, hasher ?? null, records ?? null),
|
|
118
|
+
inject: [
|
|
119
|
+
{ token: tokens_js_1.SHIELD_SESSION_TOKEN_BINDER, optional: true },
|
|
120
|
+
{ token: crypto_adapters_js_1.SHIELD_REFRESH_HASHER, optional: true },
|
|
121
|
+
{ token: mariadb_session_record_store_js_1.SHIELD_SESSION_RECORD_STORE, optional: true },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
/** Bind the `logoutRequiresProof` option (absent → `false`, the compatibility default). */
|
|
125
|
+
function makeLogoutRequiresProofProvider(requiresProof) {
|
|
126
|
+
return { provide: exports.SHIELD_LOGOUT_REQUIRES_PROOF, useValue: requiresProof === true };
|
|
127
|
+
}
|
|
@@ -33,6 +33,11 @@ export interface PendingMfaChallenge {
|
|
|
33
33
|
readonly identityHint: string;
|
|
34
34
|
/** SHA-256 of the stored credential hash at password time (credential-change guard). */
|
|
35
35
|
readonly credentialFingerprint: string;
|
|
36
|
+
/**
|
|
37
|
+
* The resolved account's tenant at password time (R12.2): the tenant of a failed second-factor
|
|
38
|
+
* audit row. Optional, so a challenge stored before this field still parses.
|
|
39
|
+
*/
|
|
40
|
+
readonly tenantId?: string;
|
|
36
41
|
}
|
|
37
42
|
/**
|
|
38
43
|
* The in-process challenge store (single instance / tests) and the class token the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pending-mfa-store.d.ts","sourceRoot":"","sources":["../../src/auth/pending-mfa-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,wFAAwF;AACxF,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,qFAAqF;IACrF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,wFAAwF;IACxF,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"pending-mfa-store.d.ts","sourceRoot":"","sources":["../../src/auth/pending-mfa-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,wFAAwF;AACxF,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,qFAAqF;IACrF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,wFAAwF;IACxF,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AA4CD;;;GAGG;AACH,qBAAa,eAAe;IAIxB,SAAS,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM;IAChC,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,KAAK;IALxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAkC;gBAGrC,KAAK,GAAE,MAAuB;IACjD,iEAAiE;IAChD,KAAK,GAAE,MAAM,MAAiB;IAGjD,uFAAuF;IACjF,KAAK,CAAC,SAAS,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;IAU5D;;;OAGG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAUjE,mFAAmF;IACnF,OAAO,CAAC,KAAK;CAMd;AAKD;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;IAErD,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,KAAK,GAAG,QAAQ,CAAC,EACrD,KAAK,GAAE,MAAuB;IAKjB,KAAK,CAAC,SAAS,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;IAYtD,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;CAU3E"}
|
|
@@ -56,6 +56,7 @@ function parseChallenge(value) {
|
|
|
56
56
|
principalId: v.principalId,
|
|
57
57
|
identityHint: v.identityHint,
|
|
58
58
|
credentialFingerprint: v.credentialFingerprint,
|
|
59
|
+
...(typeof v.tenantId === 'string' && v.tenantId !== '' ? { tenantId: v.tenantId } : {}),
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
@@ -127,6 +128,7 @@ class RedisPendingMfaStore extends PendingMfaStore {
|
|
|
127
128
|
principalId: challenge.principalId,
|
|
128
129
|
identityHint: challenge.identityHint,
|
|
129
130
|
credentialFingerprint: challenge.credentialFingerprint,
|
|
131
|
+
...(challenge.tenantId !== undefined ? { tenantId: challenge.tenantId } : {}),
|
|
130
132
|
});
|
|
131
133
|
await this.redis.set(`${REDIS_PREFIX}${tokenDigest(token)}`, value, 'PX', this.ttlMs);
|
|
132
134
|
return token;
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
import { type DynamicModule } from '@nestjs/common';
|
|
34
34
|
/**
|
|
35
35
|
* The persistence module. `forRoot()` returns the two connection providers (both
|
|
36
|
-
* exported so AuthModule can inject them)
|
|
37
|
-
* imported when auth is wired.
|
|
36
|
+
* exported so AuthModule can inject them), the shutdown holder and the audit sealer.
|
|
37
|
+
* Opt-in: only imported when auth is wired.
|
|
38
38
|
*/
|
|
39
39
|
export declare class PersistenceModule {
|
|
40
40
|
static forRoot(): DynamicModule;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"persistence.module.d.ts","sourceRoot":"","sources":["../../src/auth/persistence.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAU,KAAK,aAAa,EAAiB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"persistence.module.d.ts","sourceRoot":"","sources":["../../src/auth/persistence.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAU,KAAK,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AA2D3E;;;;GAIG;AACH,qBACa,iBAAiB;IAC5B,MAAM,CAAC,OAAO,IAAI,aAAa;CAOhC"}
|