@astram/guard 0.1.1 → 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.
- package/dist/auth/auth.controller.d.ts +102 -0
- package/dist/auth/auth.controller.d.ts.map +1 -0
- package/dist/auth/auth.controller.js +313 -0
- package/dist/auth/auth.module.d.ts +104 -0
- package/dist/auth/auth.module.d.ts.map +1 -0
- package/dist/auth/auth.module.js +318 -0
- package/dist/auth/connections.d.ts +61 -0
- package/dist/auth/connections.d.ts.map +1 -0
- package/dist/auth/connections.js +116 -0
- package/dist/auth/dto.d.ts +144 -0
- package/dist/auth/dto.d.ts.map +1 -0
- package/dist/auth/dto.js +29 -0
- package/dist/auth/pending-mfa-store.d.ts +72 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -0
- package/dist/auth/pending-mfa-store.js +106 -0
- package/dist/auth/persistence.module.d.ts +42 -0
- package/dist/auth/persistence.module.d.ts.map +1 -0
- package/dist/auth/persistence.module.js +94 -0
- package/dist/auth/tokens.d.ts +62 -0
- package/dist/auth/tokens.d.ts.map +1 -0
- package/dist/auth/tokens.js +65 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +96 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.js +156 -0
- package/dist/config/app-config.d.ts +134 -1
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +144 -2
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -1
- package/dist/module/shield.module.d.ts +51 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +65 -4
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0b Auth-surface — the framework-owned **`/auth/*` HTTP controllers**
|
|
3
|
+
* (spec §2). A consumer app gets login + MFA + refresh + logout FOR FREE by
|
|
4
|
+
* importing `ShieldModule.forRoot()` (which imports `AuthModule`); it writes no
|
|
5
|
+
* auth code. All four routes are `@Public()` (ADR-0002): they run BEFORE the
|
|
6
|
+
* caller has a session, so the global deny-by-default guard must NOT gate them.
|
|
7
|
+
*
|
|
8
|
+
* This controller is a THIN transport shim over the two composed core engines
|
|
9
|
+
* (`SHIELD_AUTHENTICATOR` / `SHIELD_SESSION_AUTHORITY`, from Team B). It holds NO
|
|
10
|
+
* business logic beyond mapping engine outcomes → HTTP status + the frozen DTO
|
|
11
|
+
* shapes (`./dto.ts`). Every security invariant is enforced here per the DTO
|
|
12
|
+
* header + the spec:
|
|
13
|
+
*
|
|
14
|
+
* ── OUTCOME → HTTP STATUS MAP (the security contract) ────────────────────────
|
|
15
|
+
* login authenticated → 200 SessionSuccess { session(no-secret), tokens }
|
|
16
|
+
* login mfa_required → 200 MfaChallengeResponse { mfaRequired, challengeToken }
|
|
17
|
+
* login invalid_credentials → 401 GENERIC ← unknown-user AND wrong-password IDENTICAL
|
|
18
|
+
* login auth_unavailable → 503 RETRYABLE ← transient blip is NOT a credential deny
|
|
19
|
+
* mfa success → 200 SessionSuccess
|
|
20
|
+
* mfa wrong/expired/forged → 401 GENERIC ← indistinguishable from a bad code
|
|
21
|
+
* mfa auth_unavailable → 503 RETRYABLE
|
|
22
|
+
* refresh ok → 200 SessionSuccess (new tokens, rotated session)
|
|
23
|
+
* refresh reuse/invalid → 401 GENERIC session_rejected (lineage-kill is the engine's)
|
|
24
|
+
* logout → 204 (idempotent; unknown/already-revoked → still 204)
|
|
25
|
+
*
|
|
26
|
+
* ── ANTI-ENUMERATION ── login + mfa failures are a SINGLE generic 401 with an
|
|
27
|
+
* identical body (`{ error: 'invalid_credentials' }`) whether the user is
|
|
28
|
+
* unknown, the password is wrong, or the factor is wrong/expired. The core
|
|
29
|
+
* authenticator's decoy-verify already equalizes timing; this layer never adds a
|
|
30
|
+
* distinguishing field/status.
|
|
31
|
+
*
|
|
32
|
+
* ── NO SECRET LEAK ── no password, hash, TOTP secret, raw token (beyond the two a
|
|
33
|
+
* success legitimately mints), or challenge internal ever appears in a body or a
|
|
34
|
+
* log. Request bodies are NEVER logged. The `SessionView` mapped from the core
|
|
35
|
+
* `Session` carries no secret by construction.
|
|
36
|
+
*
|
|
37
|
+
* ── TRANSIENT ≠ DENY (ADR-0016) ── a transient `authentication_unavailable` from
|
|
38
|
+
* the authenticator becomes a RETRYABLE 503, never a 401 — a dependency blip must
|
|
39
|
+
* not look like (or trigger the client to treat it as) a credential failure.
|
|
40
|
+
* ⚠ ENGINE-SEAM LIMIT on /auth/refresh: the core `SessionAuthority.rotate`
|
|
41
|
+
* collapses BOTH a terminal deny AND a transient store outage into one
|
|
42
|
+
* `SessionRejectedError` (its top-level fail-closed catch — see
|
|
43
|
+
* session-authority.ts). From THIS seam the two are indistinguishable, so refresh
|
|
44
|
+
* maps the generic reject to 401. Surfacing a retryable-503 on a refresh-time
|
|
45
|
+
* store outage would require the engine to expose a transient signal it does not
|
|
46
|
+
* today — flagged to the architect as a possible core-API gap (NOT worked around
|
|
47
|
+
* here; we do not invent a core API).
|
|
48
|
+
*
|
|
49
|
+
* ADR-0023: composition is the framework's job — this controller only CONSUMES the
|
|
50
|
+
* composed engines via DI tokens; it never news-up an engine or an adapter.
|
|
51
|
+
*/
|
|
52
|
+
import type { Authenticator, SessionAuthority } from '@astram/core';
|
|
53
|
+
import { PendingMfaStore } from './pending-mfa-store.js';
|
|
54
|
+
import type { LoginRequest, LoginResponse, LogoutRequest, MfaRequest, RefreshRequest, SessionSuccess } from './dto.js';
|
|
55
|
+
/**
|
|
56
|
+
* The framework-owned auth endpoints. Registered into `AuthModule`'s `controllers`
|
|
57
|
+
* by the integration owner / Team B (this controller is EXPORTED for that purpose;
|
|
58
|
+
* it does NOT edit auth.module.ts itself). Depends on the two composed engines +
|
|
59
|
+
* the pending-MFA store (provided alongside it).
|
|
60
|
+
*/
|
|
61
|
+
export declare class AuthController {
|
|
62
|
+
private readonly authenticator;
|
|
63
|
+
private readonly sessionAuthority;
|
|
64
|
+
private readonly pendingMfa;
|
|
65
|
+
constructor(authenticator: Authenticator, sessionAuthority: SessionAuthority, pendingMfa: PendingMfaStore);
|
|
66
|
+
/**
|
|
67
|
+
* `POST /auth/login` — verify a password credential and either establish a
|
|
68
|
+
* session, issue an MFA challenge, deny generically, or signal a transient
|
|
69
|
+
* outage. NEVER logs the request body.
|
|
70
|
+
*/
|
|
71
|
+
login(body: LoginRequest): Promise<LoginResponse>;
|
|
72
|
+
/**
|
|
73
|
+
* `POST /auth/mfa` — complete a pending MFA login. Consumes the single-use
|
|
74
|
+
* challenge handle (unknown/expired/forged → same generic 401 as a bad code),
|
|
75
|
+
* then RE-RUNS `authenticate(hint, [password, totp])` with the presented code
|
|
76
|
+
* (the authenticator has no standalone factor-verify seam — the full attempt IS
|
|
77
|
+
* the verify). Success → establish + tokens.
|
|
78
|
+
*/
|
|
79
|
+
mfa(body: MfaRequest): Promise<SessionSuccess>;
|
|
80
|
+
/**
|
|
81
|
+
* `POST /auth/refresh` — rotate the refresh token. Reuse/invalid/expired →
|
|
82
|
+
* generic 401 (`session_rejected`; the engine already killed the lineage,
|
|
83
|
+
* ADR-0015). ⚠ See the class header: the engine's rotate collapses a transient
|
|
84
|
+
* store outage into the SAME reject, so refresh cannot emit the ADR-0016 503 from
|
|
85
|
+
* this seam today (flagged core-API gap).
|
|
86
|
+
*/
|
|
87
|
+
refresh(body: RefreshRequest): Promise<SessionSuccess>;
|
|
88
|
+
/**
|
|
89
|
+
* `POST /auth/logout` — revoke the session. Idempotent + leak-free: revoking an
|
|
90
|
+
* unknown or already-revoked session still returns 204 (the engine's `revoke`
|
|
91
|
+
* swallows and returns void either way), so existence is never revealed.
|
|
92
|
+
*/
|
|
93
|
+
logout(body: LogoutRequest): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Establish a session for an authenticated principal, mapping the engine's
|
|
96
|
+
* generic `SessionRejectedError` (a malformed principal or a store outage at
|
|
97
|
+
* establish time) to a generic 401. We never surface a session-establish failure
|
|
98
|
+
* as a distinct, enumerable outcome.
|
|
99
|
+
*/
|
|
100
|
+
private establishOrReject;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=auth.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.controller.d.ts","sourceRoot":"","sources":["../../src/auth/auth.controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAWH,OAAO,KAAK,EACV,aAAa,EAKb,gBAAgB,EAEjB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EACV,YAAY,EACZ,aAAa,EACb,aAAa,EACb,UAAU,EACV,cAAc,EACd,cAAc,EAEf,MAAM,UAAU,CAAC;AAqElB;;;;;GAKG;AACH,qBACa,cAAc;IAGvB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAE9B,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAHV,aAAa,EAAE,aAAa,EAE5B,gBAAgB,EAAE,gBAAgB,EAClC,UAAU,EAAE,eAAe;IAG9C;;;;OAIG;IAIG,KAAK,CAAS,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC;IAgC/D;;;;;;OAMG;IAIG,GAAG,CAAS,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,cAAc,CAAC;IAkC5D;;;;;;OAMG;IAIG,OAAO,CAAS,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;IAapE;;;;OAIG;IAIG,MAAM,CAAS,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAYxD;;;;;OAKG;YACW,iBAAiB;CAOhC"}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 0b Auth-surface — the framework-owned **`/auth/*` HTTP controllers**
|
|
4
|
+
* (spec §2). A consumer app gets login + MFA + refresh + logout FOR FREE by
|
|
5
|
+
* importing `ShieldModule.forRoot()` (which imports `AuthModule`); it writes no
|
|
6
|
+
* auth code. All four routes are `@Public()` (ADR-0002): they run BEFORE the
|
|
7
|
+
* caller has a session, so the global deny-by-default guard must NOT gate them.
|
|
8
|
+
*
|
|
9
|
+
* This controller is a THIN transport shim over the two composed core engines
|
|
10
|
+
* (`SHIELD_AUTHENTICATOR` / `SHIELD_SESSION_AUTHORITY`, from Team B). It holds NO
|
|
11
|
+
* business logic beyond mapping engine outcomes → HTTP status + the frozen DTO
|
|
12
|
+
* shapes (`./dto.ts`). Every security invariant is enforced here per the DTO
|
|
13
|
+
* header + the spec:
|
|
14
|
+
*
|
|
15
|
+
* ── OUTCOME → HTTP STATUS MAP (the security contract) ────────────────────────
|
|
16
|
+
* login authenticated → 200 SessionSuccess { session(no-secret), tokens }
|
|
17
|
+
* login mfa_required → 200 MfaChallengeResponse { mfaRequired, challengeToken }
|
|
18
|
+
* login invalid_credentials → 401 GENERIC ← unknown-user AND wrong-password IDENTICAL
|
|
19
|
+
* login auth_unavailable → 503 RETRYABLE ← transient blip is NOT a credential deny
|
|
20
|
+
* mfa success → 200 SessionSuccess
|
|
21
|
+
* mfa wrong/expired/forged → 401 GENERIC ← indistinguishable from a bad code
|
|
22
|
+
* mfa auth_unavailable → 503 RETRYABLE
|
|
23
|
+
* refresh ok → 200 SessionSuccess (new tokens, rotated session)
|
|
24
|
+
* refresh reuse/invalid → 401 GENERIC session_rejected (lineage-kill is the engine's)
|
|
25
|
+
* logout → 204 (idempotent; unknown/already-revoked → still 204)
|
|
26
|
+
*
|
|
27
|
+
* ── ANTI-ENUMERATION ── login + mfa failures are a SINGLE generic 401 with an
|
|
28
|
+
* identical body (`{ error: 'invalid_credentials' }`) whether the user is
|
|
29
|
+
* unknown, the password is wrong, or the factor is wrong/expired. The core
|
|
30
|
+
* authenticator's decoy-verify already equalizes timing; this layer never adds a
|
|
31
|
+
* distinguishing field/status.
|
|
32
|
+
*
|
|
33
|
+
* ── NO SECRET LEAK ── no password, hash, TOTP secret, raw token (beyond the two a
|
|
34
|
+
* success legitimately mints), or challenge internal ever appears in a body or a
|
|
35
|
+
* log. Request bodies are NEVER logged. The `SessionView` mapped from the core
|
|
36
|
+
* `Session` carries no secret by construction.
|
|
37
|
+
*
|
|
38
|
+
* ── TRANSIENT ≠ DENY (ADR-0016) ── a transient `authentication_unavailable` from
|
|
39
|
+
* the authenticator becomes a RETRYABLE 503, never a 401 — a dependency blip must
|
|
40
|
+
* not look like (or trigger the client to treat it as) a credential failure.
|
|
41
|
+
* ⚠ ENGINE-SEAM LIMIT on /auth/refresh: the core `SessionAuthority.rotate`
|
|
42
|
+
* collapses BOTH a terminal deny AND a transient store outage into one
|
|
43
|
+
* `SessionRejectedError` (its top-level fail-closed catch — see
|
|
44
|
+
* session-authority.ts). From THIS seam the two are indistinguishable, so refresh
|
|
45
|
+
* maps the generic reject to 401. Surfacing a retryable-503 on a refresh-time
|
|
46
|
+
* store outage would require the engine to expose a transient signal it does not
|
|
47
|
+
* today — flagged to the architect as a possible core-API gap (NOT worked around
|
|
48
|
+
* here; we do not invent a core API).
|
|
49
|
+
*
|
|
50
|
+
* ADR-0023: composition is the framework's job — this controller only CONSUMES the
|
|
51
|
+
* composed engines via DI tokens; it never news-up an engine or an adapter.
|
|
52
|
+
*/
|
|
53
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
54
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
55
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
56
|
+
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;
|
|
57
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
58
|
+
};
|
|
59
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
60
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
61
|
+
};
|
|
62
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
63
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
64
|
+
};
|
|
65
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
66
|
+
exports.AuthController = void 0;
|
|
67
|
+
const common_1 = require("@nestjs/common");
|
|
68
|
+
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
69
|
+
const tokens_js_1 = require("./tokens.js");
|
|
70
|
+
const pending_mfa_store_js_1 = require("./pending-mfa-store.js");
|
|
71
|
+
/**
|
|
72
|
+
* The core `Authenticator`'s generic transient reason (see authenticator.ts —
|
|
73
|
+
* `REASON_AUTH_UNAVAILABLE`). A `rejected` result carrying THIS reason is the
|
|
74
|
+
* ADR-0016 transient signal → 503; any OTHER rejected reason is a credential deny
|
|
75
|
+
* → generic 401.
|
|
76
|
+
*/
|
|
77
|
+
const REASON_AUTH_UNAVAILABLE = 'authentication_unavailable';
|
|
78
|
+
/**
|
|
79
|
+
* The SINGLE generic body for EVERY login/mfa/refresh failure. Deliberately
|
|
80
|
+
* non-distinguishing (anti-enumeration): unknown user, wrong password, wrong/
|
|
81
|
+
* expired/forged factor, and a rejected refresh ALL return this identical body.
|
|
82
|
+
* Carries NO reason detail that could leak which check failed.
|
|
83
|
+
*/
|
|
84
|
+
const GENERIC_AUTH_FAILURE = Object.freeze({ error: 'invalid_credentials' });
|
|
85
|
+
/** The generic retryable body for a transient dependency outage (ADR-0016). */
|
|
86
|
+
const RETRYABLE_BODY = Object.freeze({ error: 'authentication_unavailable' });
|
|
87
|
+
/** Throw the generic 401 (indistinguishable across all credential-failure paths). */
|
|
88
|
+
function genericUnauthorized() {
|
|
89
|
+
throw new common_1.HttpException(GENERIC_AUTH_FAILURE, common_1.HttpStatus.UNAUTHORIZED);
|
|
90
|
+
}
|
|
91
|
+
/** Throw the retryable 503 (transient — NOT a deny; the client may retry). */
|
|
92
|
+
function retryableUnavailable() {
|
|
93
|
+
throw new common_1.HttpException(RETRYABLE_BODY, common_1.HttpStatus.SERVICE_UNAVAILABLE);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Map a core `Session` → the client-safe {@link SessionView}. Copies ONLY the
|
|
97
|
+
* non-secret lifecycle fields the DTO allows — there is no token/hash on `Session`
|
|
98
|
+
* to begin with, but this explicit projection guarantees no future `Session` field
|
|
99
|
+
* leaks into a response body by accident.
|
|
100
|
+
*/
|
|
101
|
+
function toSessionView(session) {
|
|
102
|
+
return {
|
|
103
|
+
sessionId: session.sessionId,
|
|
104
|
+
principalId: session.principalId,
|
|
105
|
+
tenantId: session.tenantId,
|
|
106
|
+
status: session.status,
|
|
107
|
+
issuedAtMs: session.issuedAtMs,
|
|
108
|
+
expiresAtMs: session.expiresAtMs,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Map a core `SessionEstablishment` → the {@link SessionSuccess} success body. */
|
|
112
|
+
function toSessionSuccess(est) {
|
|
113
|
+
return {
|
|
114
|
+
session: toSessionView(est.session),
|
|
115
|
+
tokens: {
|
|
116
|
+
accessToken: est.tokens.accessToken,
|
|
117
|
+
refreshToken: est.tokens.refreshToken,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Build the password credential the authenticator expects. */
|
|
122
|
+
function passwordCredential(password) {
|
|
123
|
+
return { factor: 'password', payload: { password } };
|
|
124
|
+
}
|
|
125
|
+
/** Build the TOTP credential the authenticator's factor verifier expects (payload.code). */
|
|
126
|
+
function totpCredential(code) {
|
|
127
|
+
return { factor: 'totp', payload: { code } };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The framework-owned auth endpoints. Registered into `AuthModule`'s `controllers`
|
|
131
|
+
* by the integration owner / Team B (this controller is EXPORTED for that purpose;
|
|
132
|
+
* it does NOT edit auth.module.ts itself). Depends on the two composed engines +
|
|
133
|
+
* the pending-MFA store (provided alongside it).
|
|
134
|
+
*/
|
|
135
|
+
let AuthController = class AuthController {
|
|
136
|
+
authenticator;
|
|
137
|
+
sessionAuthority;
|
|
138
|
+
pendingMfa;
|
|
139
|
+
constructor(authenticator, sessionAuthority, pendingMfa) {
|
|
140
|
+
this.authenticator = authenticator;
|
|
141
|
+
this.sessionAuthority = sessionAuthority;
|
|
142
|
+
this.pendingMfa = pendingMfa;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* `POST /auth/login` — verify a password credential and either establish a
|
|
146
|
+
* session, issue an MFA challenge, deny generically, or signal a transient
|
|
147
|
+
* outage. NEVER logs the request body.
|
|
148
|
+
*/
|
|
149
|
+
async login(body) {
|
|
150
|
+
const identityHint = typeof body?.identityHint === 'string' ? body.identityHint : '';
|
|
151
|
+
const password = typeof body?.password === 'string' ? body.password : '';
|
|
152
|
+
const result = await this.authenticator.authenticate(identityHint, [passwordCredential(password)]);
|
|
153
|
+
if (result.outcome === 'authenticated') {
|
|
154
|
+
const principal = requirePrincipal(result);
|
|
155
|
+
const est = await this.establishOrReject(principal);
|
|
156
|
+
return toSessionSuccess(est);
|
|
157
|
+
}
|
|
158
|
+
if (result.outcome === 'mfa_required') {
|
|
159
|
+
// Password verified, a second factor is outstanding. Hold the pending
|
|
160
|
+
// credentials server-side and hand back an OPAQUE, unforgeable challenge
|
|
161
|
+
// handle. NO session yet. The handle carries nothing about the user.
|
|
162
|
+
const challengeToken = this.pendingMfa.store(identityHint, password);
|
|
163
|
+
return { mfaRequired: true, challengeToken };
|
|
164
|
+
}
|
|
165
|
+
// outcome === 'rejected'. Distinguish ONLY transient-vs-deny (never which
|
|
166
|
+
// credential failed): a transient dependency blip is a retryable 503, anything
|
|
167
|
+
// else is the generic 401.
|
|
168
|
+
if (result.reason === REASON_AUTH_UNAVAILABLE) {
|
|
169
|
+
retryableUnavailable();
|
|
170
|
+
}
|
|
171
|
+
genericUnauthorized();
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* `POST /auth/mfa` — complete a pending MFA login. Consumes the single-use
|
|
175
|
+
* challenge handle (unknown/expired/forged → same generic 401 as a bad code),
|
|
176
|
+
* then RE-RUNS `authenticate(hint, [password, totp])` with the presented code
|
|
177
|
+
* (the authenticator has no standalone factor-verify seam — the full attempt IS
|
|
178
|
+
* the verify). Success → establish + tokens.
|
|
179
|
+
*/
|
|
180
|
+
async mfa(body) {
|
|
181
|
+
const challengeToken = typeof body?.challengeToken === 'string' ? body.challengeToken : '';
|
|
182
|
+
const code = typeof body?.code === 'string' ? body.code : '';
|
|
183
|
+
// Phase 0b: only 'totp' is real. A body claiming another factor cannot be
|
|
184
|
+
// satisfied → treat as a generic failure (no leak of "unsupported factor").
|
|
185
|
+
const factor = body?.factor;
|
|
186
|
+
// Consume the pending challenge (single-use). A miss (unknown/forged/expired)
|
|
187
|
+
// is indistinguishable from a wrong code — generic 401, no leak.
|
|
188
|
+
const pending = this.pendingMfa.consume(challengeToken);
|
|
189
|
+
if (pending === null || factor !== 'totp') {
|
|
190
|
+
genericUnauthorized();
|
|
191
|
+
}
|
|
192
|
+
const result = await this.authenticator.authenticate(pending.identityHint, [passwordCredential(pending.password), totpCredential(code)]);
|
|
193
|
+
if (result.outcome === 'authenticated') {
|
|
194
|
+
const principal = requirePrincipal(result);
|
|
195
|
+
const est = await this.establishOrReject(principal);
|
|
196
|
+
return toSessionSuccess(est);
|
|
197
|
+
}
|
|
198
|
+
// Anything else (wrong code → rejected; still-outstanding → mfa_required) is a
|
|
199
|
+
// failure. A transient blip is retryable; everything else is a generic 401.
|
|
200
|
+
// (A repeat mfa_required here means the code did not satisfy the factor.)
|
|
201
|
+
if (result.reason === REASON_AUTH_UNAVAILABLE) {
|
|
202
|
+
retryableUnavailable();
|
|
203
|
+
}
|
|
204
|
+
genericUnauthorized();
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* `POST /auth/refresh` — rotate the refresh token. Reuse/invalid/expired →
|
|
208
|
+
* generic 401 (`session_rejected`; the engine already killed the lineage,
|
|
209
|
+
* ADR-0015). ⚠ See the class header: the engine's rotate collapses a transient
|
|
210
|
+
* store outage into the SAME reject, so refresh cannot emit the ADR-0016 503 from
|
|
211
|
+
* this seam today (flagged core-API gap).
|
|
212
|
+
*/
|
|
213
|
+
async refresh(body) {
|
|
214
|
+
const refreshToken = typeof body?.refreshToken === 'string' ? body.refreshToken : '';
|
|
215
|
+
try {
|
|
216
|
+
const est = await this.sessionAuthority.rotate(refreshToken);
|
|
217
|
+
return toSessionSuccess(est);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// The engine throws only its generic SessionRejectedError (reuse, invalid,
|
|
221
|
+
// expiry, or a swallowed store outage — all indistinguishable here). Map to a
|
|
222
|
+
// generic 401; never leak which, never echo the token.
|
|
223
|
+
genericUnauthorized();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `POST /auth/logout` — revoke the session. Idempotent + leak-free: revoking an
|
|
228
|
+
* unknown or already-revoked session still returns 204 (the engine's `revoke`
|
|
229
|
+
* swallows and returns void either way), so existence is never revealed.
|
|
230
|
+
*/
|
|
231
|
+
async logout(body) {
|
|
232
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : '';
|
|
233
|
+
// `revoke` is void + best-effort-idempotent by contract; it never throws a
|
|
234
|
+
// resource-existence signal. We still guard against an unexpected throw so a
|
|
235
|
+
// logout never 500s (which could itself be an existence oracle).
|
|
236
|
+
try {
|
|
237
|
+
await this.sessionAuthority.revoke(sessionId);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Swallow — logout is idempotent + must not leak. 204 regardless.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Establish a session for an authenticated principal, mapping the engine's
|
|
245
|
+
* generic `SessionRejectedError` (a malformed principal or a store outage at
|
|
246
|
+
* establish time) to a generic 401. We never surface a session-establish failure
|
|
247
|
+
* as a distinct, enumerable outcome.
|
|
248
|
+
*/
|
|
249
|
+
async establishOrReject(principal) {
|
|
250
|
+
try {
|
|
251
|
+
return await this.sessionAuthority.establish(principal);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
genericUnauthorized();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
exports.AuthController = AuthController;
|
|
259
|
+
__decorate([
|
|
260
|
+
(0, classification_decorator_js_1.Public)(),
|
|
261
|
+
(0, common_1.Post)('login'),
|
|
262
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
263
|
+
__param(0, (0, common_1.Body)()),
|
|
264
|
+
__metadata("design:type", Function),
|
|
265
|
+
__metadata("design:paramtypes", [Object]),
|
|
266
|
+
__metadata("design:returntype", Promise)
|
|
267
|
+
], AuthController.prototype, "login", null);
|
|
268
|
+
__decorate([
|
|
269
|
+
(0, classification_decorator_js_1.Public)(),
|
|
270
|
+
(0, common_1.Post)('mfa'),
|
|
271
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
272
|
+
__param(0, (0, common_1.Body)()),
|
|
273
|
+
__metadata("design:type", Function),
|
|
274
|
+
__metadata("design:paramtypes", [Object]),
|
|
275
|
+
__metadata("design:returntype", Promise)
|
|
276
|
+
], AuthController.prototype, "mfa", null);
|
|
277
|
+
__decorate([
|
|
278
|
+
(0, classification_decorator_js_1.Public)(),
|
|
279
|
+
(0, common_1.Post)('refresh'),
|
|
280
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
281
|
+
__param(0, (0, common_1.Body)()),
|
|
282
|
+
__metadata("design:type", Function),
|
|
283
|
+
__metadata("design:paramtypes", [Object]),
|
|
284
|
+
__metadata("design:returntype", Promise)
|
|
285
|
+
], AuthController.prototype, "refresh", null);
|
|
286
|
+
__decorate([
|
|
287
|
+
(0, classification_decorator_js_1.Public)(),
|
|
288
|
+
(0, common_1.Post)('logout'),
|
|
289
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
|
|
290
|
+
__param(0, (0, common_1.Body)()),
|
|
291
|
+
__metadata("design:type", Function),
|
|
292
|
+
__metadata("design:paramtypes", [Object]),
|
|
293
|
+
__metadata("design:returntype", Promise)
|
|
294
|
+
], AuthController.prototype, "logout", null);
|
|
295
|
+
exports.AuthController = AuthController = __decorate([
|
|
296
|
+
(0, common_1.Controller)('auth'),
|
|
297
|
+
__param(0, (0, common_1.Inject)(tokens_js_1.SHIELD_AUTHENTICATOR)),
|
|
298
|
+
__param(1, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_AUTHORITY)),
|
|
299
|
+
__metadata("design:paramtypes", [Object, Object, pending_mfa_store_js_1.PendingMfaStore])
|
|
300
|
+
], AuthController);
|
|
301
|
+
/**
|
|
302
|
+
* Narrow an `authenticated` result to its guaranteed principal. The core engine
|
|
303
|
+
* populates `principal` ONLY on `outcome === 'authenticated'`; this asserts that
|
|
304
|
+
* invariant so a missing principal fails closed (generic 401) rather than
|
|
305
|
+
* establishing a session for `undefined`.
|
|
306
|
+
*/
|
|
307
|
+
function requirePrincipal(result) {
|
|
308
|
+
const principal = result.principal;
|
|
309
|
+
if (principal === undefined || principal === null) {
|
|
310
|
+
genericUnauthorized();
|
|
311
|
+
}
|
|
312
|
+
return principal;
|
|
313
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AuthModule` — the Phase 0b **composition seam** that assembles the core auth
|
|
3
|
+
* engines from guard's adapters + the live connections, and exposes them under
|
|
4
|
+
* {@link SHIELD_AUTHENTICATOR} / {@link SHIELD_SESSION_AUTHORITY} for Team C's
|
|
5
|
+
* `/auth/*` controllers (spec §4).
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* Team B (connection + composition) OWNS this file. It wires the frozen tokens to
|
|
9
|
+
* the real engines:
|
|
10
|
+
*
|
|
11
|
+
* SHIELD_PASSWORD_HASHER → new Argon2idPasswordHasher() (the flagged fix —
|
|
12
|
+
* the authenticator factory injects it, so THIS module must provide it).
|
|
13
|
+
* SHIELD_CREDENTIAL_RESOLVER → new MariaDbCredentialResolver(pool) (Team A's
|
|
14
|
+
* class; its `resolve()` body is Team A's — until then it throws not-implemented,
|
|
15
|
+
* which the engine treats as fail-closed `authentication_unavailable`).
|
|
16
|
+
* SHIELD_SESSION_RECORD_STORE → new MariaDbSessionRecordStore(pool).
|
|
17
|
+
* SHIELD_HOT_SESSION_STORE → new RedisHotSessionStore(redis).
|
|
18
|
+
* SHIELD_TOKEN_GENERATOR → new CryptoTokenGenerator().
|
|
19
|
+
* SHIELD_CLOCK → new SystemClock().
|
|
20
|
+
* SHIELD_REFRESH_HASHER → new Sha256RefreshTokenHasher(
|
|
21
|
+
* assertRefreshHmacKey(config.refreshHmacKey)) ⛔ FAIL-CLOSED gate:
|
|
22
|
+
* `assertRefreshHmacKey` THROWS on a blank key, so auth can NEVER compose with
|
|
23
|
+
* an unkeyed refresh-token hasher. This is the boot-time fail-closed point for
|
|
24
|
+
* the required secret (loadAppConfig deliberately does NOT throw, so a
|
|
25
|
+
* non-auth scaffold still boots with no `.env` — see app-config.ts).
|
|
26
|
+
*
|
|
27
|
+
* SHIELD_SESSION_AUTHORITY → createSessionAuthority({ recordStore, hotStore,
|
|
28
|
+
* tokenGenerator, refreshHasher, clock, lifetimes: config.lifetimes }).
|
|
29
|
+
* `createSessionAuthority` validates lifetime bounds eagerly (throws LOUD on a
|
|
30
|
+
* nonsensical config — construction-time, correct).
|
|
31
|
+
*
|
|
32
|
+
* SHIELD_AUTHENTICATOR → createAuthenticator({ hasher, resolver,
|
|
33
|
+
* identityBroker: createIdentityBroker(), // default Internal, empty
|
|
34
|
+
* factorVerifiers: [ createTotpFactorVerifier({ hmacSha1 }) ] }). The TOTP
|
|
35
|
+
* verifier is wired so MFA works; `hmacSha1` is the {@link hmacSha1} node:crypto
|
|
36
|
+
* one-liner (guard owns node:crypto, core does not). `createIdentityBroker()`
|
|
37
|
+
* with no args registers the default Internal adapter with an EMPTY superuser
|
|
38
|
+
* allowlist (default-deny).
|
|
39
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* ⛔ OPT-IN (how the integration owner gates this — for the no-`.env` boot):
|
|
42
|
+
* AuthModule opens live connections (via PersistenceModule) and fires the
|
|
43
|
+
* fail-closed `assertRefreshHmacKey` gate the moment it composes. So it MUST be
|
|
44
|
+
* imported ONLY when a deployment wants auth. `ShieldModule.forRoot(options)`
|
|
45
|
+
* should conditionally add `AuthModule.forRoot()` to its `imports` gated behind an
|
|
46
|
+
* option (e.g. `ShieldModule.forRoot({ auth: true })`) or an env flag:
|
|
47
|
+
*
|
|
48
|
+
* imports: [
|
|
49
|
+
* EnforcementModule, BootModule,
|
|
50
|
+
* ...(options?.auth ? [AuthModule.forRoot()] : []), // ← opt-in
|
|
51
|
+
* ],
|
|
52
|
+
* exports: [APP_CONFIG, SHIELD_DECISION_AUTHORITY,
|
|
53
|
+
* ...(options?.auth ? [SHIELD_AUTHENTICATOR, SHIELD_SESSION_AUTHORITY] : [])],
|
|
54
|
+
*
|
|
55
|
+
* An enforcement-only app that does NOT opt in never imports AuthModule → never
|
|
56
|
+
* opens a pool/redis → never calls `assertRefreshHmacKey` → keeps the no-`.env`
|
|
57
|
+
* boot. An auth-enabled app fails LOUD at boot if `SHIELD_REFRESH_HMAC_KEY` is
|
|
58
|
+
* blank — auth can never compose unkeyed. `APP_CONFIG` is already `@Global` from
|
|
59
|
+
* `ShieldModule`, so AuthModule's config-injecting factories resolve. See DONE.md.
|
|
60
|
+
*
|
|
61
|
+
* ADR-0003: the core factories (`createAuthenticator`/`createSessionAuthority`/
|
|
62
|
+
* `createIdentityBroker`/`createTotpFactorVerifier`) are pure; the adapters + the
|
|
63
|
+
* node:crypto HMAC live in guard. Core stays zero-dep + unchanged.
|
|
64
|
+
*/
|
|
65
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
66
|
+
import type { CredentialResolver, HmacSha1 } from '@astram/core';
|
|
67
|
+
/**
|
|
68
|
+
* The injected HMAC-SHA1 primitive the core TOTP verifier needs (Core is zero-dep
|
|
69
|
+
* + node-type-free, so it cannot import `node:crypto`). Guard owns node:crypto and
|
|
70
|
+
* supplies this one-liner: raw key bytes + message bytes → the 20-byte HMAC-SHA1
|
|
71
|
+
* digest. Never logs the key or message.
|
|
72
|
+
*/
|
|
73
|
+
export declare const hmacSha1: HmacSha1;
|
|
74
|
+
/**
|
|
75
|
+
* Options for {@link AuthModule.forRoot} — registration-time collaborators the
|
|
76
|
+
* consumer plugs through {@link ShieldModule.forRoot}.
|
|
77
|
+
*/
|
|
78
|
+
export interface AuthModuleOptions {
|
|
79
|
+
/**
|
|
80
|
+
* OPTIONAL custom credential resolver (the store login checks against). Omit →
|
|
81
|
+
* the framework's default {@link MariaDbCredentialResolver} (reads
|
|
82
|
+
* `shield_identity.identity_credential`). Supply a consumer implementation of the
|
|
83
|
+
* core `CredentialResolver` port to authenticate against your OWN user/identity
|
|
84
|
+
* schema. Only meaningful here (AuthModule is only imported under `auth: true`).
|
|
85
|
+
*/
|
|
86
|
+
readonly credentialResolver?: CredentialResolver;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The auth composition module. Imports {@link PersistenceModule} (the live
|
|
90
|
+
* connections) and provides the adapters + composed engines. Exports the two
|
|
91
|
+
* engines (Team C consumes them) and the resolver token. Opt-in: imported ONLY by
|
|
92
|
+
* an auth-enabled `ShieldModule.forRoot({ auth: true })` — see the file header +
|
|
93
|
+
* DONE.md for the exact gating the integration owner applies.
|
|
94
|
+
*/
|
|
95
|
+
export declare class AuthModule {
|
|
96
|
+
/**
|
|
97
|
+
* Compose the auth surface. `options.credentialResolver`, when supplied, REPLACES
|
|
98
|
+
* the default MariaDb store with the consumer's own (a value provider under
|
|
99
|
+
* {@link SHIELD_CREDENTIAL_RESOLVER}); omitted → the historical default binding,
|
|
100
|
+
* EXACTLY unchanged (zero behavior change for existing consumers).
|
|
101
|
+
*/
|
|
102
|
+
static forRoot(options?: AuthModuleOptions): DynamicModule;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=auth.module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AAEH,OAAO,EAAU,KAAK,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AAa3E,OAAO,KAAK,EAGV,kBAAkB,EAOlB,QAAQ,EACT,MAAM,cAAc,CAAC;AA+BtB;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,EAAE,QACqE,CAAC;AAiL7F;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CAClD;AAED;;;;;;GAMG;AACH,qBACa,UAAU;IACrB;;;;;OAKG;IACH,MAAM,CAAC,OAAO,CAAC,OAAO,GAAE,iBAAsB,GAAG,aAAa;CA8B/D"}
|