@astram/guard 0.1.0 → 0.2.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.
Files changed (34) hide show
  1. package/dist/auth/auth.controller.d.ts +102 -0
  2. package/dist/auth/auth.controller.d.ts.map +1 -0
  3. package/dist/auth/auth.controller.js +313 -0
  4. package/dist/auth/auth.module.d.ts +104 -0
  5. package/dist/auth/auth.module.d.ts.map +1 -0
  6. package/dist/auth/auth.module.js +318 -0
  7. package/dist/auth/connections.d.ts +61 -0
  8. package/dist/auth/connections.d.ts.map +1 -0
  9. package/dist/auth/connections.js +116 -0
  10. package/dist/auth/dto.d.ts +144 -0
  11. package/dist/auth/dto.d.ts.map +1 -0
  12. package/dist/auth/dto.js +29 -0
  13. package/dist/auth/pending-mfa-store.d.ts +72 -0
  14. package/dist/auth/pending-mfa-store.d.ts.map +1 -0
  15. package/dist/auth/pending-mfa-store.js +106 -0
  16. package/dist/auth/persistence.module.d.ts +42 -0
  17. package/dist/auth/persistence.module.d.ts.map +1 -0
  18. package/dist/auth/persistence.module.js +94 -0
  19. package/dist/auth/tokens.d.ts +62 -0
  20. package/dist/auth/tokens.d.ts.map +1 -0
  21. package/dist/auth/tokens.js +65 -0
  22. package/dist/authentication/mariadb-credential-resolver.d.ts +96 -0
  23. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -0
  24. package/dist/authentication/mariadb-credential-resolver.js +156 -0
  25. package/dist/config/app-config.d.ts +134 -1
  26. package/dist/config/app-config.d.ts.map +1 -1
  27. package/dist/config/app-config.js +144 -2
  28. package/dist/index.d.ts +9 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +46 -1
  31. package/dist/module/shield.module.d.ts +51 -0
  32. package/dist/module/shield.module.d.ts.map +1 -1
  33. package/dist/module/shield.module.js +65 -4
  34. package/package.json +9 -9
@@ -0,0 +1,318 @@
1
+ "use strict";
2
+ /**
3
+ * `AuthModule` — the Phase 0b **composition seam** that assembles the core auth
4
+ * engines from guard's adapters + the live connections, and exposes them under
5
+ * {@link SHIELD_AUTHENTICATOR} / {@link SHIELD_SESSION_AUTHORITY} for Team C's
6
+ * `/auth/*` controllers (spec §4).
7
+ *
8
+ * ─────────────────────────────────────────────────────────────────────────────
9
+ * Team B (connection + composition) OWNS this file. It wires the frozen tokens to
10
+ * the real engines:
11
+ *
12
+ * SHIELD_PASSWORD_HASHER → new Argon2idPasswordHasher() (the flagged fix —
13
+ * the authenticator factory injects it, so THIS module must provide it).
14
+ * SHIELD_CREDENTIAL_RESOLVER → new MariaDbCredentialResolver(pool) (Team A's
15
+ * class; its `resolve()` body is Team A's — until then it throws not-implemented,
16
+ * which the engine treats as fail-closed `authentication_unavailable`).
17
+ * SHIELD_SESSION_RECORD_STORE → new MariaDbSessionRecordStore(pool).
18
+ * SHIELD_HOT_SESSION_STORE → new RedisHotSessionStore(redis).
19
+ * SHIELD_TOKEN_GENERATOR → new CryptoTokenGenerator().
20
+ * SHIELD_CLOCK → new SystemClock().
21
+ * SHIELD_REFRESH_HASHER → new Sha256RefreshTokenHasher(
22
+ * assertRefreshHmacKey(config.refreshHmacKey)) ⛔ FAIL-CLOSED gate:
23
+ * `assertRefreshHmacKey` THROWS on a blank key, so auth can NEVER compose with
24
+ * an unkeyed refresh-token hasher. This is the boot-time fail-closed point for
25
+ * the required secret (loadAppConfig deliberately does NOT throw, so a
26
+ * non-auth scaffold still boots with no `.env` — see app-config.ts).
27
+ *
28
+ * SHIELD_SESSION_AUTHORITY → createSessionAuthority({ recordStore, hotStore,
29
+ * tokenGenerator, refreshHasher, clock, lifetimes: config.lifetimes }).
30
+ * `createSessionAuthority` validates lifetime bounds eagerly (throws LOUD on a
31
+ * nonsensical config — construction-time, correct).
32
+ *
33
+ * SHIELD_AUTHENTICATOR → createAuthenticator({ hasher, resolver,
34
+ * identityBroker: createIdentityBroker(), // default Internal, empty
35
+ * factorVerifiers: [ createTotpFactorVerifier({ hmacSha1 }) ] }). The TOTP
36
+ * verifier is wired so MFA works; `hmacSha1` is the {@link hmacSha1} node:crypto
37
+ * one-liner (guard owns node:crypto, core does not). `createIdentityBroker()`
38
+ * with no args registers the default Internal adapter with an EMPTY superuser
39
+ * allowlist (default-deny).
40
+ * ─────────────────────────────────────────────────────────────────────────────
41
+ *
42
+ * ⛔ OPT-IN (how the integration owner gates this — for the no-`.env` boot):
43
+ * AuthModule opens live connections (via PersistenceModule) and fires the
44
+ * fail-closed `assertRefreshHmacKey` gate the moment it composes. So it MUST be
45
+ * imported ONLY when a deployment wants auth. `ShieldModule.forRoot(options)`
46
+ * should conditionally add `AuthModule.forRoot()` to its `imports` gated behind an
47
+ * option (e.g. `ShieldModule.forRoot({ auth: true })`) or an env flag:
48
+ *
49
+ * imports: [
50
+ * EnforcementModule, BootModule,
51
+ * ...(options?.auth ? [AuthModule.forRoot()] : []), // ← opt-in
52
+ * ],
53
+ * exports: [APP_CONFIG, SHIELD_DECISION_AUTHORITY,
54
+ * ...(options?.auth ? [SHIELD_AUTHENTICATOR, SHIELD_SESSION_AUTHORITY] : [])],
55
+ *
56
+ * An enforcement-only app that does NOT opt in never imports AuthModule → never
57
+ * opens a pool/redis → never calls `assertRefreshHmacKey` → keeps the no-`.env`
58
+ * boot. An auth-enabled app fails LOUD at boot if `SHIELD_REFRESH_HMAC_KEY` is
59
+ * blank — auth can never compose unkeyed. `APP_CONFIG` is already `@Global` from
60
+ * `ShieldModule`, so AuthModule's config-injecting factories resolve. See DONE.md.
61
+ *
62
+ * ADR-0003: the core factories (`createAuthenticator`/`createSessionAuthority`/
63
+ * `createIdentityBroker`/`createTotpFactorVerifier`) are pure; the adapters + the
64
+ * node:crypto HMAC live in guard. Core stays zero-dep + unchanged.
65
+ */
66
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
67
+ if (k2 === undefined) k2 = k;
68
+ var desc = Object.getOwnPropertyDescriptor(m, k);
69
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
70
+ desc = { enumerable: true, get: function() { return m[k]; } };
71
+ }
72
+ Object.defineProperty(o, k2, desc);
73
+ }) : (function(o, m, k, k2) {
74
+ if (k2 === undefined) k2 = k;
75
+ o[k2] = m[k];
76
+ }));
77
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
78
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
79
+ }) : function(o, v) {
80
+ o["default"] = v;
81
+ });
82
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
83
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
84
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
85
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
86
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
87
+ };
88
+ var __importStar = (this && this.__importStar) || (function () {
89
+ var ownKeys = function(o) {
90
+ ownKeys = Object.getOwnPropertyNames || function (o) {
91
+ var ar = [];
92
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
93
+ return ar;
94
+ };
95
+ return ownKeys(o);
96
+ };
97
+ return function (mod) {
98
+ if (mod && mod.__esModule) return mod;
99
+ var result = {};
100
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
101
+ __setModuleDefault(result, mod);
102
+ return result;
103
+ };
104
+ })();
105
+ var AuthModule_1;
106
+ Object.defineProperty(exports, "__esModule", { value: true });
107
+ exports.AuthModule = exports.hmacSha1 = void 0;
108
+ const common_1 = require("@nestjs/common");
109
+ const node_crypto_1 = require("node:crypto");
110
+ const app_config_js_1 = require("../config/app-config.js");
111
+ const config_module_js_1 = require("../config/config.module.js");
112
+ const argon2id_password_hasher_js_1 = require("../authentication/argon2id-password-hasher.js");
113
+ const mariadb_credential_resolver_js_1 = require("../authentication/mariadb-credential-resolver.js");
114
+ const index_js_1 = require("../session/index.js");
115
+ const persistence_module_js_1 = require("./persistence.module.js");
116
+ const auth_controller_js_1 = require("./auth.controller.js");
117
+ const pending_mfa_store_js_1 = require("./pending-mfa-store.js");
118
+ const tokens_js_1 = require("./tokens.js");
119
+ /**
120
+ * The injected HMAC-SHA1 primitive the core TOTP verifier needs (Core is zero-dep
121
+ * + node-type-free, so it cannot import `node:crypto`). Guard owns node:crypto and
122
+ * supplies this one-liner: raw key bytes + message bytes → the 20-byte HMAC-SHA1
123
+ * digest. Never logs the key or message.
124
+ */
125
+ const hmacSha1 = (key, message) => new Uint8Array((0, node_crypto_1.createHmac)('sha1', Buffer.from(key)).update(Buffer.from(message)).digest());
126
+ exports.hmacSha1 = hmacSha1;
127
+ /**
128
+ * A `Function`-wrapped dynamic `import()` that survives the CommonJS **build**.
129
+ * Under `module: commonjs`, `tsc` rewrites a literal `import()` into
130
+ * `Promise.resolve().then(() => require(...))` — which fails on the ESM-only core.
131
+ * Routing the import through the `Function` constructor hides it from that
132
+ * transform, so the emitted dist keeps a genuine native `import()` and Node's
133
+ * CJS→ESM interop loads core at runtime. (This path is used by the BUILT dist —
134
+ * e.g. example-backend. It does NOT work under vitest, whose sandbox can't resolve
135
+ * a specifier from an eval'd context — hence the two-strategy loadCore() below.)
136
+ */
137
+ const evalImport = new Function('specifier', 'return import(specifier);');
138
+ let cachedCore;
139
+ /**
140
+ * Lazily load `@astram/core`'s four auth factory functions — invoked ONLY inside
141
+ * the two async provider factories (i.e. only when `forRoot()` actually composes
142
+ * auth), NEVER at module top-level. This keeps a runtime `require('@astram/core')`
143
+ * out of the guard barrel's load path (see the top-of-file note), so importing
144
+ * `@astram/guard`'s built dist for the enforcement-only path does not touch core.
145
+ *
146
+ * TWO strategies, so it works in BOTH runtimes:
147
+ * 1. A plain dynamic `import('@astram/core')` — the vitest/SWC source path keeps
148
+ * this native, and vitest's resolver maps the workspace package. (In the
149
+ * tsc-built CJS dist this literal is downleveled to a `require`, which throws on
150
+ * the ESM core — caught below.)
151
+ * 2. On failure, the {@link evalImport} `Function`-wrapped native `import()` — the
152
+ * BUILT dist path (Node CJS→ESM interop), which vitest can't use.
153
+ * Cached after the first success.
154
+ */
155
+ async function loadCore() {
156
+ if (cachedCore !== undefined)
157
+ return cachedCore;
158
+ try {
159
+ // Strategy 1 — native in vitest/SWC; a `require` (that may throw) in the CJS dist.
160
+ cachedCore = (await Promise.resolve().then(() => __importStar(require('@astram/core'))));
161
+ }
162
+ catch {
163
+ // Strategy 2 — the built-dist path: a genuine `import()` via the Function shim.
164
+ cachedCore = (await evalImport('@astram/core'));
165
+ }
166
+ return cachedCore;
167
+ }
168
+ // --- Adapter-backed providers (Team A resolver + session adapters) ----------
169
+ /** Argon2id password hasher (⛔ the flagged fix — provide it HERE so the authenticator factory's `inject` resolves). */
170
+ const passwordHasherProvider = {
171
+ provide: argon2id_password_hasher_js_1.SHIELD_PASSWORD_HASHER,
172
+ useFactory: () => new argon2id_password_hasher_js_1.Argon2idPasswordHasher(),
173
+ };
174
+ /**
175
+ * Credential resolver (the store login checks against).
176
+ *
177
+ * DEFAULT (no consumer override): a `useFactory` that constructs the framework's
178
+ * {@link MariaDbCredentialResolver} injected with the live {@link SHIELD_DB_POOL}
179
+ * — the historical binding, unchanged.
180
+ *
181
+ * OVERRIDE (Phase 1a seam): when the consumer supplies a `credentialResolver`
182
+ * through {@link ShieldModuleOptions.credentialResolver} → {@link AuthModule.forRoot},
183
+ * the token instead binds a plain **value provider** to that instance, so login
184
+ * authenticates against the consumer's OWN store (e.g. a richer users/roles model)
185
+ * WITHOUT editing the framework. The seam swaps the STORE only — the supplied
186
+ * resolver still flows through the SAME composed authenticator (anti-enumeration
187
+ * decoy verify, MFA, no-secret-leak); its `null`/throw contract is unchanged.
188
+ */
189
+ function makeCredentialResolverProvider(override) {
190
+ return override
191
+ ? { provide: tokens_js_1.SHIELD_CREDENTIAL_RESOLVER, useValue: override }
192
+ : {
193
+ provide: tokens_js_1.SHIELD_CREDENTIAL_RESOLVER,
194
+ useFactory: (pool) => new mariadb_credential_resolver_js_1.MariaDbCredentialResolver(pool),
195
+ inject: [tokens_js_1.SHIELD_DB_POOL],
196
+ };
197
+ }
198
+ /** Durable session record store (MariaDB adapter, injected with the live pool). */
199
+ const sessionRecordStoreProvider = {
200
+ provide: index_js_1.SHIELD_SESSION_RECORD_STORE,
201
+ useFactory: (pool) => new index_js_1.MariaDbSessionRecordStore(pool),
202
+ inject: [tokens_js_1.SHIELD_DB_POOL],
203
+ };
204
+ /** Hot session store (Redis adapter, injected with the live client). */
205
+ const hotSessionStoreProvider = {
206
+ provide: index_js_1.SHIELD_HOT_SESSION_STORE,
207
+ useFactory: (redis) => new index_js_1.RedisHotSessionStore(redis),
208
+ inject: [tokens_js_1.SHIELD_REDIS],
209
+ };
210
+ /** Opaque token generator (node:crypto adapter). */
211
+ const tokenGeneratorProvider = {
212
+ provide: index_js_1.SHIELD_TOKEN_GENERATOR,
213
+ useFactory: () => new index_js_1.CryptoTokenGenerator(),
214
+ };
215
+ /**
216
+ * Keyed refresh hasher (HMAC adapter). ⛔ FAIL-CLOSED HMAC-key gate:
217
+ * `assertRefreshHmacKey` THROWS on a blank key, so auth can never compose with an
218
+ * unkeyed refresh-token hasher.
219
+ */
220
+ const refreshHasherProvider = {
221
+ provide: index_js_1.SHIELD_REFRESH_HASHER,
222
+ useFactory: (config) => new index_js_1.Sha256RefreshTokenHasher((0, app_config_js_1.assertRefreshHmacKey)(config.refreshHmacKey)),
223
+ inject: [config_module_js_1.APP_CONFIG],
224
+ };
225
+ /** System clock. */
226
+ const clockProvider = {
227
+ provide: index_js_1.SHIELD_CLOCK,
228
+ useFactory: () => new index_js_1.SystemClock(),
229
+ };
230
+ // --- Composed core engines --------------------------------------------------
231
+ /** The composed SessionAuthority (core) — establish / rotate / revoke. */
232
+ const sessionAuthorityProvider = {
233
+ provide: tokens_js_1.SHIELD_SESSION_AUTHORITY,
234
+ // async: loads the core factory lazily (CJS→ESM, see loadCore()); Nest awaits it.
235
+ useFactory: async (recordStore, hotStore, tokenGenerator, refreshHasher, clock, config) => {
236
+ const { createSessionAuthority } = await loadCore();
237
+ return createSessionAuthority({
238
+ recordStore,
239
+ hotStore,
240
+ tokenGenerator,
241
+ refreshHasher,
242
+ clock,
243
+ lifetimes: config.lifetimes,
244
+ });
245
+ },
246
+ inject: [
247
+ index_js_1.SHIELD_SESSION_RECORD_STORE,
248
+ index_js_1.SHIELD_HOT_SESSION_STORE,
249
+ index_js_1.SHIELD_TOKEN_GENERATOR,
250
+ index_js_1.SHIELD_REFRESH_HASHER,
251
+ index_js_1.SHIELD_CLOCK,
252
+ config_module_js_1.APP_CONFIG,
253
+ ],
254
+ };
255
+ /** The composed Authenticator (core) — password + TOTP factor, anti-enumeration. */
256
+ const authenticatorProvider = {
257
+ provide: tokens_js_1.SHIELD_AUTHENTICATOR,
258
+ // async: loads the core factories lazily (CJS→ESM, see loadCore()); Nest awaits it.
259
+ useFactory: async (hasher, resolver) => {
260
+ const { createAuthenticator, createIdentityBroker, createTotpFactorVerifier } = await loadCore();
261
+ return createAuthenticator({
262
+ hasher,
263
+ resolver,
264
+ identityBroker: createIdentityBroker(),
265
+ factorVerifiers: [createTotpFactorVerifier({ hmacSha1: exports.hmacSha1 })],
266
+ });
267
+ },
268
+ inject: [argon2id_password_hasher_js_1.SHIELD_PASSWORD_HASHER, tokens_js_1.SHIELD_CREDENTIAL_RESOLVER],
269
+ };
270
+ /**
271
+ * The auth composition module. Imports {@link PersistenceModule} (the live
272
+ * connections) and provides the adapters + composed engines. Exports the two
273
+ * engines (Team C consumes them) and the resolver token. Opt-in: imported ONLY by
274
+ * an auth-enabled `ShieldModule.forRoot({ auth: true })` — see the file header +
275
+ * DONE.md for the exact gating the integration owner applies.
276
+ */
277
+ let AuthModule = AuthModule_1 = class AuthModule {
278
+ /**
279
+ * Compose the auth surface. `options.credentialResolver`, when supplied, REPLACES
280
+ * the default MariaDb store with the consumer's own (a value provider under
281
+ * {@link SHIELD_CREDENTIAL_RESOLVER}); omitted → the historical default binding,
282
+ * EXACTLY unchanged (zero behavior change for existing consumers).
283
+ */
284
+ static forRoot(options = {}) {
285
+ return {
286
+ module: AuthModule_1,
287
+ imports: [persistence_module_js_1.PersistenceModule.forRoot()],
288
+ // Team C's four `@Public()` /auth/* endpoints. Registered here (integration
289
+ // wiring) so the routes exist the moment auth is opted in — the controller
290
+ // injects the two composed engines (below) + PendingMfaStore.
291
+ controllers: [auth_controller_js_1.AuthController],
292
+ providers: [
293
+ passwordHasherProvider,
294
+ makeCredentialResolverProvider(options.credentialResolver),
295
+ sessionRecordStoreProvider,
296
+ hotSessionStoreProvider,
297
+ tokenGeneratorProvider,
298
+ refreshHasherProvider,
299
+ clockProvider,
300
+ sessionAuthorityProvider,
301
+ authenticatorProvider,
302
+ // Team C's server-side, single-use, TTL'd challenge store backing the
303
+ // login→mfa handoff (a plain class provider the controller injects).
304
+ pending_mfa_store_js_1.PendingMfaStore,
305
+ ],
306
+ exports: [
307
+ tokens_js_1.SHIELD_AUTHENTICATOR,
308
+ tokens_js_1.SHIELD_SESSION_AUTHORITY,
309
+ tokens_js_1.SHIELD_CREDENTIAL_RESOLVER,
310
+ argon2id_password_hasher_js_1.SHIELD_PASSWORD_HASHER,
311
+ ],
312
+ };
313
+ }
314
+ };
315
+ exports.AuthModule = AuthModule;
316
+ exports.AuthModule = AuthModule = AuthModule_1 = __decorate([
317
+ (0, common_1.Module)({})
318
+ ], AuthModule);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Live-connection factories + a shutdown registrar for the Phase 0b auth surface
3
+ * (Team B). This is the ONE module in the framework that opens a real MariaDB pool
4
+ * and a real Redis client (spec §4); everything auth persists (sessions,
5
+ * credentials, hot state) flows through the two connections created here.
6
+ *
7
+ * WHY a separate helper (not inline in the provider factory): a Nest `useFactory`
8
+ * value provider is NOT itself lifecycle-aware — `onModuleDestroy` only fires on
9
+ * PROVIDER CLASSES with that method. So the pool/redis are created by the pure
10
+ * factories below, and a tiny {@link ConnectionLifecycle} provider (a class Nest
11
+ * WILL call `onModuleDestroy` on) holds their handles and closes them on shutdown.
12
+ * This keeps `SHIELD_DB_POOL` / `SHIELD_REDIS` as the raw driver objects the
13
+ * adapters expect (`new MariaDbSessionRecordStore(pool)` etc.) while still getting
14
+ * graceful teardown.
15
+ *
16
+ * ⛔ SECURITY — never logs a credential. The DB password + Redis url (which may
17
+ * carry a password) are handed straight to the drivers; nothing here stringifies
18
+ * or logs the config.
19
+ *
20
+ * ADR-0003: the real `mariadb` / `ioredis` live in guard (Core stays zero-dep).
21
+ * ADR-0018: the pool cap is taken from the connection-pool budget
22
+ * (`config.pool.perAccountMax.shield_core`), NOT a duplicate knob.
23
+ */
24
+ import { type OnModuleDestroy } from '@nestjs/common';
25
+ import { type Pool } from 'mariadb';
26
+ import Redis from 'ioredis';
27
+ import type { AppConfig } from '../config/app-config.js';
28
+ /**
29
+ * Create the ONE live `mariadb` {@link Pool} for the `shield_core` account from
30
+ * {@link AppConfig}. The `connectionLimit` comes from the ADR-0018 budget
31
+ * (`config.pool.perAccountMax.shield_core`) so per-worker pools stay within the DB
32
+ * `max_connections` ceiling — it is NOT a separate configurable.
33
+ *
34
+ * A blank `db.password` is ALLOWED here (it is not hard-required at config load);
35
+ * a wrong/blank password fails LOUD at connect time, which is the correct place
36
+ * for a runtime credential error. The host/port/user/database always have
37
+ * documented defaults, so the descriptor is always well-formed.
38
+ */
39
+ export declare function createDbPool(config: AppConfig): Pool;
40
+ /**
41
+ * Create the ONE live `ioredis` client (the hot session tier, ADR-0013) from
42
+ * {@link AppConfig}. `config.redis.url` is the canonical `ioredis`-native form
43
+ * (`redis://[:pw@]host:port[/db]`) with a documented default. Lazy-connect is left
44
+ * at ioredis's default (connects on first command); a dead Redis surfaces as the
45
+ * `unavailable` hot-store outcome (fail-closed), never a silent `absent`.
46
+ */
47
+ export declare function createRedisClient(config: AppConfig): Redis;
48
+ /**
49
+ * Lifecycle-aware holder that owns the pool + redis handles and closes them on
50
+ * app shutdown. Provided ALONGSIDE the two raw-connection providers so Nest calls
51
+ * `onModuleDestroy` here (value providers get no lifecycle callback). The teardown
52
+ * is best-effort + idempotent: a close error is swallowed so shutdown never hangs
53
+ * or throws, and both connections are always attempted.
54
+ */
55
+ export declare class ConnectionLifecycle implements OnModuleDestroy {
56
+ private readonly pool;
57
+ private readonly redis;
58
+ constructor(pool: Pool, redis: Redis);
59
+ onModuleDestroy(): Promise<void>;
60
+ }
61
+ //# sourceMappingURL=connections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connections.d.ts","sourceRoot":"","sources":["../../src/auth/connections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,KAAK,MAAM,SAAS,CAAC;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAcpD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,KAAK,CAI1D;AAED;;;;;;GAMG;AACH,qBACa,mBAAoB,YAAW,eAAe;IAEvD,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,KAAK;IAGzB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;CAavC"}
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ /**
3
+ * Live-connection factories + a shutdown registrar for the Phase 0b auth surface
4
+ * (Team B). This is the ONE module in the framework that opens a real MariaDB pool
5
+ * and a real Redis client (spec §4); everything auth persists (sessions,
6
+ * credentials, hot state) flows through the two connections created here.
7
+ *
8
+ * WHY a separate helper (not inline in the provider factory): a Nest `useFactory`
9
+ * value provider is NOT itself lifecycle-aware — `onModuleDestroy` only fires on
10
+ * PROVIDER CLASSES with that method. So the pool/redis are created by the pure
11
+ * factories below, and a tiny {@link ConnectionLifecycle} provider (a class Nest
12
+ * WILL call `onModuleDestroy` on) holds their handles and closes them on shutdown.
13
+ * This keeps `SHIELD_DB_POOL` / `SHIELD_REDIS` as the raw driver objects the
14
+ * adapters expect (`new MariaDbSessionRecordStore(pool)` etc.) while still getting
15
+ * graceful teardown.
16
+ *
17
+ * ⛔ SECURITY — never logs a credential. The DB password + Redis url (which may
18
+ * carry a password) are handed straight to the drivers; nothing here stringifies
19
+ * or logs the config.
20
+ *
21
+ * ADR-0003: the real `mariadb` / `ioredis` live in guard (Core stays zero-dep).
22
+ * ADR-0018: the pool cap is taken from the connection-pool budget
23
+ * (`config.pool.perAccountMax.shield_core`), NOT a duplicate knob.
24
+ */
25
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
26
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
27
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
28
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
29
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
30
+ };
31
+ var __metadata = (this && this.__metadata) || function (k, v) {
32
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
33
+ };
34
+ var __importDefault = (this && this.__importDefault) || function (mod) {
35
+ return (mod && mod.__esModule) ? mod : { "default": mod };
36
+ };
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.ConnectionLifecycle = void 0;
39
+ exports.createDbPool = createDbPool;
40
+ exports.createRedisClient = createRedisClient;
41
+ const common_1 = require("@nestjs/common");
42
+ const mariadb_1 = __importDefault(require("mariadb"));
43
+ const ioredis_1 = __importDefault(require("ioredis"));
44
+ /**
45
+ * Create the ONE live `mariadb` {@link Pool} for the `shield_core` account from
46
+ * {@link AppConfig}. The `connectionLimit` comes from the ADR-0018 budget
47
+ * (`config.pool.perAccountMax.shield_core`) so per-worker pools stay within the DB
48
+ * `max_connections` ceiling — it is NOT a separate configurable.
49
+ *
50
+ * A blank `db.password` is ALLOWED here (it is not hard-required at config load);
51
+ * a wrong/blank password fails LOUD at connect time, which is the correct place
52
+ * for a runtime credential error. The host/port/user/database always have
53
+ * documented defaults, so the descriptor is always well-formed.
54
+ */
55
+ function createDbPool(config) {
56
+ const { db, pool } = config;
57
+ return mariadb_1.default.createPool({
58
+ host: db.host,
59
+ port: db.port,
60
+ user: db.user,
61
+ password: db.password,
62
+ database: db.database,
63
+ connectionLimit: pool.perAccountMax.shield_core,
64
+ // Fail fast on an unreachable DB rather than hanging the boot indefinitely.
65
+ connectTimeout: 10_000,
66
+ // All platform timestamps are UTC (parity with the session adapters).
67
+ timezone: 'Z',
68
+ });
69
+ }
70
+ /**
71
+ * Create the ONE live `ioredis` client (the hot session tier, ADR-0013) from
72
+ * {@link AppConfig}. `config.redis.url` is the canonical `ioredis`-native form
73
+ * (`redis://[:pw@]host:port[/db]`) with a documented default. Lazy-connect is left
74
+ * at ioredis's default (connects on first command); a dead Redis surfaces as the
75
+ * `unavailable` hot-store outcome (fail-closed), never a silent `absent`.
76
+ */
77
+ function createRedisClient(config) {
78
+ // `maxRetriesPerRequest: null` keeps a transient blip from turning into a thrown
79
+ // command (the hot-store adapter classifies unreachable as `unavailable`).
80
+ return new ioredis_1.default(config.redis.url, { maxRetriesPerRequest: null, lazyConnect: false });
81
+ }
82
+ /**
83
+ * Lifecycle-aware holder that owns the pool + redis handles and closes them on
84
+ * app shutdown. Provided ALONGSIDE the two raw-connection providers so Nest calls
85
+ * `onModuleDestroy` here (value providers get no lifecycle callback). The teardown
86
+ * is best-effort + idempotent: a close error is swallowed so shutdown never hangs
87
+ * or throws, and both connections are always attempted.
88
+ */
89
+ let ConnectionLifecycle = class ConnectionLifecycle {
90
+ pool;
91
+ redis;
92
+ constructor(pool, redis) {
93
+ this.pool = pool;
94
+ this.redis = redis;
95
+ }
96
+ async onModuleDestroy() {
97
+ // Close both; never let one failure prevent the other, never throw.
98
+ try {
99
+ await this.pool.end();
100
+ }
101
+ catch {
102
+ // best-effort — a pool already ending / a mid-query close is non-fatal here.
103
+ }
104
+ try {
105
+ await this.redis.quit();
106
+ }
107
+ catch {
108
+ // best-effort — quit on an already-closed client is non-fatal.
109
+ }
110
+ }
111
+ };
112
+ exports.ConnectionLifecycle = ConnectionLifecycle;
113
+ exports.ConnectionLifecycle = ConnectionLifecycle = __decorate([
114
+ (0, common_1.Injectable)(),
115
+ __metadata("design:paramtypes", [Object, ioredis_1.default])
116
+ ], ConnectionLifecycle);
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Phase 0b Auth-surface **endpoint DTOs / view types** — the request/response
3
+ * SHAPES the four `@Public()` `/auth/*` endpoints speak (spec §2). ⛔ FROZEN by
4
+ * this prelude; **Team C builds controllers to these**. Types only — no NestJS /
5
+ * class-validator decorators here (the prelude froze the wire shape; Team C adds
6
+ * the validation decorators on its own DTO classes if it wants runtime
7
+ * validation, keeping this file framework-agnostic and importable anywhere).
8
+ *
9
+ * ─────────────────────────────────────────────────────────────────────────────
10
+ * SECURITY RULES these shapes ENCODE (spec §2, ACs 2/3/6; enforce in controllers):
11
+ *
12
+ * • ANTI-ENUMERATION — `/auth/login` and `/auth/mfa` failure responses are a
13
+ * SINGLE generic 401 for {unknown user, wrong password, wrong/expired factor}.
14
+ * The response body MUST NOT distinguish which failed (the engine's decoy-verify
15
+ * already equalizes timing). NEVER add a `reason`/`field`/`code` that leaks
16
+ * "no such user" vs "bad password".
17
+ * • NO SECRET LEAK — NO response/log ever carries a password, a password hash, a
18
+ * TOTP secret, or a RAW refresh/access token OTHER than the two token fields a
19
+ * successful lifecycle event legitimately returns ({@link SessionTokensView}).
20
+ * The {@link SessionView} below deliberately OMITS every secret — it is the
21
+ * ONLY session shape a response body may embed.
22
+ * • FAIL-CLOSED — a store/transient failure on refresh is the ADR-0016 retryable
23
+ * signal (503-style), NOT a terminal deny/logout (see {@link RefreshResponse}).
24
+ * ─────────────────────────────────────────────────────────────────────────────
25
+ *
26
+ * ADR-0003: framework-agnostic — no framework / HTTP / DB imports.
27
+ */
28
+ /**
29
+ * The client-safe view of an established session. Carries ONLY non-secret
30
+ * lifecycle facts a caller may see. ⛔ It MUST NOT contain any token, hash, or
31
+ * secret — tokens travel ONLY in {@link SessionTokensView}. `principalId` /
32
+ * `tenantId` are opaque internal ids (not rendered to end users as UUIDs — the UI
33
+ * resolves display labels; these are for the client's own session bookkeeping).
34
+ */
35
+ export interface SessionView {
36
+ readonly sessionId: string;
37
+ readonly principalId: string;
38
+ readonly tenantId: string;
39
+ readonly status: 'active' | 'rotated' | 'revoked' | 'expired';
40
+ /** UTC epoch ms. */
41
+ readonly issuedAtMs: number;
42
+ /** UTC epoch ms. */
43
+ readonly expiresAtMs: number;
44
+ }
45
+ /**
46
+ * The token pair minted at a session lifecycle event. ⛔ These are SECRETS in
47
+ * transit — returned to the caller EXACTLY at login / mfa-success / refresh, never
48
+ * persisted raw, never logged. The refresh token is stored server-side only as
49
+ * its keyed hash.
50
+ */
51
+ export interface SessionTokensView {
52
+ readonly accessToken: string;
53
+ readonly refreshToken: string;
54
+ }
55
+ /** A successful session establishment payload (login / mfa / refresh success). */
56
+ export interface SessionSuccess {
57
+ readonly session: SessionView;
58
+ readonly tokens: SessionTokensView;
59
+ }
60
+ /** `POST /auth/login` request body. */
61
+ export interface LoginRequest {
62
+ /** Username or email — resolved to a principal by the credential resolver. */
63
+ readonly identityHint: string;
64
+ /** ⛔ The plaintext password — verified then discarded; NEVER logged/echoed. */
65
+ readonly password: string;
66
+ }
67
+ /**
68
+ * The MFA-challenge branch of a login/step-up response: the password was correct
69
+ * but a second factor is required, so NO session is established yet. The caller
70
+ * re-submits to `/auth/mfa` with the `challengeToken`. ⛔ `challengeToken` is a
71
+ * short-TTL opaque handle to the pending authentication — it is NOT a session
72
+ * token and grants no access on its own; it carries no secret about the user.
73
+ */
74
+ export interface MfaChallengeResponse {
75
+ readonly mfaRequired: true;
76
+ readonly challengeToken: string;
77
+ }
78
+ /**
79
+ * `POST /auth/login` response — a discriminated union:
80
+ * • {@link SessionSuccess} — password-only user, authenticated + session;
81
+ * • {@link MfaChallengeResponse} — MFA-enabled user, challenge issued (no session).
82
+ * A FAILURE (unknown user / wrong password) is NOT modeled here — it is a generic
83
+ * HTTP 401 with a non-distinguishing body (anti-enumeration; see file header).
84
+ */
85
+ export type LoginResponse = SessionSuccess | MfaChallengeResponse;
86
+ /**
87
+ * `POST /auth/mfa` request body — completes a pending MFA challenge. `factor` is
88
+ * `'totp'` in Phase 0b (the only real factor; WebAuthn/recovery are contract slots
89
+ * only). `code` is the presented one-time code (digits). ⛔ `code` is single-use
90
+ * material — never logged.
91
+ */
92
+ export interface MfaRequest {
93
+ /** The opaque handle from {@link MfaChallengeResponse}. Short-TTL, single-use. */
94
+ readonly challengeToken: string;
95
+ /** The second-factor class being satisfied (Phase 0b: `'totp'`). */
96
+ readonly factor: 'totp';
97
+ /** The presented one-time code. NEVER logged. */
98
+ readonly code: string;
99
+ }
100
+ /**
101
+ * `POST /auth/mfa` response — on success, the session + tokens
102
+ * ({@link SessionSuccess}). A wrong/expired code is a generic HTTP 401 (no session,
103
+ * no leak of why), NOT modeled as a body variant.
104
+ */
105
+ export type MfaResponse = SessionSuccess;
106
+ /**
107
+ * `POST /auth/refresh` request body. The refresh token may alternatively arrive
108
+ * via an HttpOnly cookie (Team C's transport choice) — the shape is the same.
109
+ * ⛔ Secret — never logged; server matches it by keyed hash only.
110
+ */
111
+ export interface RefreshRequest {
112
+ readonly refreshToken: string;
113
+ }
114
+ /**
115
+ * `POST /auth/refresh` response — new tokens + the (rotated) session on success
116
+ * ({@link SessionSuccess}).
117
+ *
118
+ * ⛔ TWO distinct failure modes the controller MUST keep separate (spec §2, AC 7):
119
+ * • reuse/invalid/expired token → generic `session_rejected` (HTTP 401), lineage
120
+ * killed (ADR-0015) — a TERMINAL deny.
121
+ * • transient store outage (hot store unavailable) → the ADR-0016 RETRYABLE
122
+ * signal (HTTP 503-style), NOT a deny/logout. Conflating the two either logs
123
+ * everyone out on a blip or fails open — both are bugs.
124
+ * Neither failure is a body variant here; both are HTTP-status outcomes with
125
+ * non-secret bodies.
126
+ */
127
+ export type RefreshResponse = SessionSuccess;
128
+ /**
129
+ * `POST /auth/logout` request body. `sessionId` may alternatively be derived from
130
+ * the access token (Team C's choice). Idempotent — revoking an already-revoked /
131
+ * unknown session still succeeds (no existence leak).
132
+ */
133
+ export interface LogoutRequest {
134
+ readonly sessionId: string;
135
+ }
136
+ /**
137
+ * `POST /auth/logout` acknowledgement. The endpoint returns HTTP 204 (no body) on
138
+ * success; this type documents an OPTIONAL ack body if Team C prefers 200 — it
139
+ * carries NO secret and NO session detail (just a flag). Prefer 204 per spec §2.
140
+ */
141
+ export interface LogoutAck {
142
+ readonly revoked: true;
143
+ }
144
+ //# sourceMappingURL=dto.d.ts.map
@@ -0,0 +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,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,iBAAiB,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;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,cAAc,GAAG,oBAAoB,CAAC;AAMlE;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,kFAAkF;IAClF,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,cAAc,CAAC;AAMzC;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC;AAM7C;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;CACxB"}