@nage-api/auth 1.0.0-beta.2
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/LICENSE +202 -0
- package/README.md +176 -0
- package/dist/api-key.service.d.ts +50 -0
- package/dist/api-key.service.js +110 -0
- package/dist/auth.controller.d.ts +48 -0
- package/dist/auth.controller.js +185 -0
- package/dist/auth.dto.d.ts +37 -0
- package/dist/auth.dto.js +117 -0
- package/dist/auth.guard.d.ts +29 -0
- package/dist/auth.guard.js +122 -0
- package/dist/auth.module.d.ts +61 -0
- package/dist/auth.module.js +226 -0
- package/dist/auth.service.d.ts +82 -0
- package/dist/auth.service.js +269 -0
- package/dist/authorization.guard.d.ts +24 -0
- package/dist/authorization.guard.js +107 -0
- package/dist/config.d.ts +71 -0
- package/dist/config.js +151 -0
- package/dist/decorators.d.ts +51 -0
- package/dist/decorators.js +70 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +96 -0
- package/dist/jwt.d.ts +50 -0
- package/dist/jwt.js +163 -0
- package/dist/lockout.service.d.ts +43 -0
- package/dist/lockout.service.js +94 -0
- package/dist/memory-stores.d.ts +84 -0
- package/dist/memory-stores.js +246 -0
- package/dist/otp.service.d.ts +47 -0
- package/dist/otp.service.js +137 -0
- package/dist/password.d.ts +51 -0
- package/dist/password.js +122 -0
- package/dist/policy.d.ts +44 -0
- package/dist/policy.js +61 -0
- package/dist/ports.d.ts +175 -0
- package/dist/ports.js +17 -0
- package/dist/principal.resolver.d.ts +52 -0
- package/dist/principal.resolver.js +125 -0
- package/dist/session.service.d.ts +71 -0
- package/dist/session.service.js +175 -0
- package/dist/tokens.d.ts +22 -0
- package/dist/tokens.js +23 -0
- package/package.json +66 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `NageAuthModule` — one `forRoot` that registers both guards globally
|
|
4
|
+
* (PLAN.md §15.1, §11.1).
|
|
5
|
+
*
|
|
6
|
+
* The module owns the composition: an application supplies stores and secrets,
|
|
7
|
+
* and gets back a protected surface. It deliberately does **not** offer a way
|
|
8
|
+
* to register the controller without the guards — a package that lets you take
|
|
9
|
+
* the endpoints and skip the protection is a footgun with a `forRoot`.
|
|
10
|
+
*
|
|
11
|
+
* Every store defaults to its in-memory implementation so the module is usable
|
|
12
|
+
* (and testable) before a database exists; a real deployment passes its own,
|
|
13
|
+
* and `assertProductionStores` refuses the in-memory ones there.
|
|
14
|
+
*/
|
|
15
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
16
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
17
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
18
|
+
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;
|
|
19
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
20
|
+
};
|
|
21
|
+
var NageAuthModule_1;
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.NageAuthModule = void 0;
|
|
24
|
+
const common_1 = require("@nestjs/common");
|
|
25
|
+
const core_1 = require("@nestjs/core");
|
|
26
|
+
const core_2 = require("@nage-api/core");
|
|
27
|
+
const api_key_service_js_1 = require("./api-key.service.js");
|
|
28
|
+
const auth_controller_js_1 = require("./auth.controller.js");
|
|
29
|
+
const auth_guard_js_1 = require("./auth.guard.js");
|
|
30
|
+
const auth_service_js_1 = require("./auth.service.js");
|
|
31
|
+
const authorization_guard_js_1 = require("./authorization.guard.js");
|
|
32
|
+
const jwt_js_1 = require("./jwt.js");
|
|
33
|
+
const lockout_service_js_1 = require("./lockout.service.js");
|
|
34
|
+
const otp_service_js_1 = require("./otp.service.js");
|
|
35
|
+
const policy_js_1 = require("./policy.js");
|
|
36
|
+
const principal_resolver_js_1 = require("./principal.resolver.js");
|
|
37
|
+
const session_service_js_1 = require("./session.service.js");
|
|
38
|
+
const password_js_1 = require("./password.js");
|
|
39
|
+
const config_js_1 = require("./config.js");
|
|
40
|
+
const memory_stores_js_1 = require("./memory-stores.js");
|
|
41
|
+
const tokens_js_1 = require("./tokens.js");
|
|
42
|
+
let NageAuthModule = NageAuthModule_1 = class NageAuthModule {
|
|
43
|
+
static forRoot(options) {
|
|
44
|
+
const config = (0, config_js_1.resolveAuthConfig)({
|
|
45
|
+
...(options.auth === undefined ? {} : { auth: options.auth }),
|
|
46
|
+
environment: options.environment,
|
|
47
|
+
...(options.roles === undefined ? {} : { roles: options.roles }),
|
|
48
|
+
...(options.impersonation === undefined ? {} : { impersonation: options.impersonation }),
|
|
49
|
+
});
|
|
50
|
+
const stores = {
|
|
51
|
+
users: options.users ?? new memory_stores_js_1.MemoryAuthUserStore(),
|
|
52
|
+
sessions: options.sessions ?? new memory_stores_js_1.MemorySessionStore(),
|
|
53
|
+
otpStore: options.otpStore ?? new memory_stores_js_1.MemoryOtpStore(),
|
|
54
|
+
otpChannel: options.otpChannel ?? new memory_stores_js_1.MemoryOtpChannel(),
|
|
55
|
+
apiKeys: options.apiKeys ?? new memory_stores_js_1.MemoryApiKeyStore(),
|
|
56
|
+
lockout: options.lockout ?? new memory_stores_js_1.MemoryLockoutStore(),
|
|
57
|
+
};
|
|
58
|
+
assertProductionStores(config, options, stores);
|
|
59
|
+
const hasher = options.hasher ?? new password_js_1.Argon2PasswordHasher({ pepper: options.secrets.passwordPepper });
|
|
60
|
+
const signer = options.signer ??
|
|
61
|
+
new jwt_js_1.JoseTokenSigner({
|
|
62
|
+
config,
|
|
63
|
+
privateKey: options.secrets.jwtPrivateKey,
|
|
64
|
+
...(options.secrets.jwtPublicKey === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { publicKey: options.secrets.jwtPublicKey }),
|
|
67
|
+
});
|
|
68
|
+
const sessionService = new session_service_js_1.SessionService({
|
|
69
|
+
store: stores.sessions,
|
|
70
|
+
config,
|
|
71
|
+
pepper: options.secrets.sessionPepper,
|
|
72
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
73
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
74
|
+
});
|
|
75
|
+
const lockoutService = new lockout_service_js_1.LockoutService({
|
|
76
|
+
store: stores.lockout,
|
|
77
|
+
config: config.lockout,
|
|
78
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
79
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
80
|
+
});
|
|
81
|
+
const otpService = new otp_service_js_1.OtpService({
|
|
82
|
+
store: stores.otpStore,
|
|
83
|
+
channel: stores.otpChannel,
|
|
84
|
+
config: config.otp,
|
|
85
|
+
pepper: options.secrets.otpPepper,
|
|
86
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
87
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
88
|
+
});
|
|
89
|
+
const authService = new auth_service_js_1.AuthService({
|
|
90
|
+
config,
|
|
91
|
+
users: stores.users,
|
|
92
|
+
sessions: sessionService,
|
|
93
|
+
hasher,
|
|
94
|
+
signer,
|
|
95
|
+
otp: otpService,
|
|
96
|
+
lockout: lockoutService,
|
|
97
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
98
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
99
|
+
});
|
|
100
|
+
const principals = new principal_resolver_js_1.PrincipalResolver({
|
|
101
|
+
users: stores.users,
|
|
102
|
+
sessions: stores.sessions,
|
|
103
|
+
roles: config.roles,
|
|
104
|
+
...(options.principalCacheTtlMs === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { cacheTtlMs: options.principalCacheTtlMs }),
|
|
107
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
108
|
+
});
|
|
109
|
+
const policies = new policy_js_1.PolicyRegistry(options.policies ?? {});
|
|
110
|
+
const apiKeyService = config.apiKeys.enabled
|
|
111
|
+
? new api_key_service_js_1.ApiKeyService({
|
|
112
|
+
store: stores.apiKeys,
|
|
113
|
+
pepper: requireApiKeyPepper(options.secrets),
|
|
114
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
115
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
116
|
+
})
|
|
117
|
+
: undefined;
|
|
118
|
+
const providers = [
|
|
119
|
+
{ provide: tokens_js_1.NAGE_AUTH_CONFIG, useValue: config },
|
|
120
|
+
{ provide: tokens_js_1.NAGE_AUTH_USER_STORE, useValue: stores.users },
|
|
121
|
+
{ provide: tokens_js_1.NAGE_SESSION_STORE, useValue: stores.sessions },
|
|
122
|
+
{ provide: tokens_js_1.NAGE_OTP_STORE, useValue: stores.otpStore },
|
|
123
|
+
{ provide: tokens_js_1.NAGE_OTP_CHANNEL, useValue: stores.otpChannel },
|
|
124
|
+
{ provide: tokens_js_1.NAGE_API_KEY_STORE, useValue: stores.apiKeys },
|
|
125
|
+
{ provide: tokens_js_1.NAGE_LOCKOUT_STORE, useValue: stores.lockout },
|
|
126
|
+
{ provide: tokens_js_1.NAGE_PASSWORD_HASHER, useValue: hasher },
|
|
127
|
+
{ provide: tokens_js_1.NAGE_TOKEN_SIGNER, useValue: signer },
|
|
128
|
+
{ provide: session_service_js_1.SessionService, useValue: sessionService },
|
|
129
|
+
{ provide: lockout_service_js_1.LockoutService, useValue: lockoutService },
|
|
130
|
+
{ provide: otp_service_js_1.OtpService, useValue: otpService },
|
|
131
|
+
{ provide: auth_service_js_1.AuthService, useValue: authService },
|
|
132
|
+
{ provide: principal_resolver_js_1.PrincipalResolver, useValue: principals },
|
|
133
|
+
{ provide: policy_js_1.PolicyRegistry, useValue: policies },
|
|
134
|
+
// Order matters: Nest runs global guards in registration order, and
|
|
135
|
+
// authorization has nothing to authorize until authentication has run.
|
|
136
|
+
{
|
|
137
|
+
provide: core_1.APP_GUARD,
|
|
138
|
+
inject: [core_1.Reflector],
|
|
139
|
+
useFactory: (reflector) => new auth_guard_js_1.AuthGuard({
|
|
140
|
+
signer,
|
|
141
|
+
principals,
|
|
142
|
+
reflector,
|
|
143
|
+
...(apiKeyService === undefined ? {} : { apiKeys: apiKeyService }),
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
provide: core_1.APP_GUARD,
|
|
148
|
+
inject: [core_1.Reflector],
|
|
149
|
+
useFactory: (reflector) => new authorization_guard_js_1.AuthorizationGuard({ reflector, policies }),
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
if (apiKeyService !== undefined) {
|
|
153
|
+
providers.push({ provide: api_key_service_js_1.ApiKeyService, useValue: apiKeyService });
|
|
154
|
+
}
|
|
155
|
+
if (options.audit !== undefined) {
|
|
156
|
+
providers.push({ provide: tokens_js_1.NAGE_AUTH_AUDIT, useValue: options.audit });
|
|
157
|
+
}
|
|
158
|
+
if (options.clock !== undefined) {
|
|
159
|
+
providers.push({ provide: tokens_js_1.NAGE_AUTH_CLOCK, useValue: options.clock });
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
module: NageAuthModule_1,
|
|
163
|
+
controllers: [auth_controller_js_1.AuthController],
|
|
164
|
+
providers,
|
|
165
|
+
exports: [
|
|
166
|
+
auth_service_js_1.AuthService,
|
|
167
|
+
session_service_js_1.SessionService,
|
|
168
|
+
otp_service_js_1.OtpService,
|
|
169
|
+
lockout_service_js_1.LockoutService,
|
|
170
|
+
principal_resolver_js_1.PrincipalResolver,
|
|
171
|
+
policy_js_1.PolicyRegistry,
|
|
172
|
+
tokens_js_1.NAGE_AUTH_CONFIG,
|
|
173
|
+
tokens_js_1.NAGE_AUTH_USER_STORE,
|
|
174
|
+
tokens_js_1.NAGE_SESSION_STORE,
|
|
175
|
+
tokens_js_1.NAGE_PASSWORD_HASHER,
|
|
176
|
+
tokens_js_1.NAGE_TOKEN_SIGNER,
|
|
177
|
+
...(apiKeyService === undefined ? [] : [api_key_service_js_1.ApiKeyService]),
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
exports.NageAuthModule = NageAuthModule;
|
|
183
|
+
exports.NageAuthModule = NageAuthModule = NageAuthModule_1 = __decorate([
|
|
184
|
+
(0, common_1.Module)({})
|
|
185
|
+
], NageAuthModule);
|
|
186
|
+
/**
|
|
187
|
+
* Refuse to boot production on the in-memory stores.
|
|
188
|
+
*
|
|
189
|
+
* They lose every session on restart and are not shared between instances, so
|
|
190
|
+
* a deployment that reaches them has a bug that would otherwise present as
|
|
191
|
+
* "users are randomly logged out" months later.
|
|
192
|
+
*/
|
|
193
|
+
function assertProductionStores(config, options, stores) {
|
|
194
|
+
if (config.environment !== 'production' && config.environment !== 'staging')
|
|
195
|
+
return;
|
|
196
|
+
const defaulted = [
|
|
197
|
+
options.users === undefined && stores.users instanceof memory_stores_js_1.MemoryAuthUserStore
|
|
198
|
+
? 'users'
|
|
199
|
+
: undefined,
|
|
200
|
+
options.sessions === undefined && stores.sessions instanceof memory_stores_js_1.MemorySessionStore
|
|
201
|
+
? 'sessions'
|
|
202
|
+
: undefined,
|
|
203
|
+
options.otpStore === undefined && stores.otpStore instanceof memory_stores_js_1.MemoryOtpStore
|
|
204
|
+
? 'otpStore'
|
|
205
|
+
: undefined,
|
|
206
|
+
options.lockout === undefined && stores.lockout instanceof memory_stores_js_1.MemoryLockoutStore
|
|
207
|
+
? 'lockout'
|
|
208
|
+
: undefined,
|
|
209
|
+
].filter((name) => name !== undefined);
|
|
210
|
+
if (defaulted.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
throw new core_2.ConfigurationError({
|
|
213
|
+
detail: `NageAuthModule is using in-memory stores in ${config.environment}: ${defaulted.join(', ')}. They are per-process and lost on restart.`,
|
|
214
|
+
meta: { environment: config.environment, stores: defaulted },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function requireApiKeyPepper(secrets) {
|
|
218
|
+
if (secrets.apiKeyPepper === undefined || secrets.apiKeyPepper === '') {
|
|
219
|
+
throw new core_2.ConfigurationError({
|
|
220
|
+
detail: 'auth.apiKeys is enabled but no apiKeyPepper was supplied; set API_KEY_PEPPER',
|
|
221
|
+
meta: { setting: 'secrets.apiKeyPepper' },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return secrets.apiKeyPepper;
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=auth.module.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authentication flows (PLAN.md §15.1).
|
|
3
|
+
*
|
|
4
|
+
* The legacy endpoint surface is retained — `POST /auth/local`, `/auth/token`,
|
|
5
|
+
* `/auth/logout`, `/auth/password/forgot`, `/auth/password/reset`,
|
|
6
|
+
* `/auth/otp/send`, `/auth/otp/verify`, admin `/auth/user` — because breaking
|
|
7
|
+
* every existing client is not a security improvement. What changes is
|
|
8
|
+
* everything behind them.
|
|
9
|
+
*
|
|
10
|
+
* Two properties run through the whole file:
|
|
11
|
+
*
|
|
12
|
+
* **No enumeration.** Whether an email is registered is not something an
|
|
13
|
+
* unauthenticated caller may learn. A login against an unknown address still
|
|
14
|
+
* performs a hash verification so it takes the same time, and forgot-password
|
|
15
|
+
* answers identically either way.
|
|
16
|
+
*
|
|
17
|
+
* **Nothing partial.** A password reset revokes every session; a login records
|
|
18
|
+
* its own failures; a refresh either rotates or fails. There is no state where
|
|
19
|
+
* a credential is half-changed.
|
|
20
|
+
*/
|
|
21
|
+
import type { AuthUser, Id, TokenPair } from '@nage-api/contracts';
|
|
22
|
+
import { LockoutService } from './lockout.service.js';
|
|
23
|
+
import type { OtpService } from './otp.service.js';
|
|
24
|
+
import type { SessionContext, SessionService } from './session.service.js';
|
|
25
|
+
import type { ResolvedAuthConfig } from './config.js';
|
|
26
|
+
import type { AuthAuditSink, AuthUserStore, Clock, PasswordHasher, TokenSigner } from './ports.js';
|
|
27
|
+
export interface AuthServiceOptions {
|
|
28
|
+
readonly config: ResolvedAuthConfig;
|
|
29
|
+
readonly users: AuthUserStore;
|
|
30
|
+
readonly sessions: SessionService;
|
|
31
|
+
readonly hasher: PasswordHasher;
|
|
32
|
+
readonly signer: TokenSigner;
|
|
33
|
+
readonly otp: OtpService;
|
|
34
|
+
readonly lockout: LockoutService;
|
|
35
|
+
readonly audit?: AuthAuditSink;
|
|
36
|
+
readonly clock?: Clock;
|
|
37
|
+
}
|
|
38
|
+
export interface LoginInput {
|
|
39
|
+
readonly email: string;
|
|
40
|
+
readonly password: string;
|
|
41
|
+
readonly context?: SessionContext;
|
|
42
|
+
}
|
|
43
|
+
export interface ResetPasswordInput {
|
|
44
|
+
readonly email: string;
|
|
45
|
+
readonly code: string;
|
|
46
|
+
readonly password: string;
|
|
47
|
+
}
|
|
48
|
+
export declare class AuthService {
|
|
49
|
+
#private;
|
|
50
|
+
constructor(options: AuthServiceOptions);
|
|
51
|
+
/** `POST /auth/local` — email and password. */
|
|
52
|
+
login(input: LoginInput): Promise<TokenPair>;
|
|
53
|
+
/** `POST /auth/token` — exchange a refresh token, rotating it. */
|
|
54
|
+
refresh(refreshToken: string, context?: SessionContext): Promise<TokenPair>;
|
|
55
|
+
/** `POST /auth/logout` — revoke the whole session family. */
|
|
56
|
+
logout(refreshToken: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* `POST /auth/password/forgot` — send a reset code.
|
|
59
|
+
*
|
|
60
|
+
* Returns nothing and never fails for an unknown address: the response is
|
|
61
|
+
* identical whether or not the account exists.
|
|
62
|
+
*/
|
|
63
|
+
forgotPassword(email: string): Promise<void>;
|
|
64
|
+
/** `POST /auth/password/reset` — consume the code and set a new password. */
|
|
65
|
+
resetPassword(input: ResetPasswordInput): Promise<void>;
|
|
66
|
+
/** `POST /auth/otp/send` — a login code. Silent for unknown addresses. */
|
|
67
|
+
sendLoginOtp(email: string): Promise<void>;
|
|
68
|
+
/** `POST /auth/otp/verify` — exchange a login code for tokens. */
|
|
69
|
+
verifyLoginOtp(email: string, code: string, context?: SessionContext): Promise<TokenPair>;
|
|
70
|
+
/**
|
|
71
|
+
* Admin impersonation (`POST /auth/user`), retained from the legacy surface.
|
|
72
|
+
*
|
|
73
|
+
* The issued token records who is impersonating in an RFC 8693 `act` claim,
|
|
74
|
+
* so every downstream log line and audit row shows both identities. It is
|
|
75
|
+
* short-lived and cannot be chained: an impersonated session may not
|
|
76
|
+
* impersonate again.
|
|
77
|
+
*/
|
|
78
|
+
impersonate(actor: AuthUser, targetUserId: Id, context?: SessionContext): Promise<TokenPair>;
|
|
79
|
+
}
|
|
80
|
+
/** Emails are compared case-insensitively; storing them normalised is the app's job. */
|
|
81
|
+
export declare function normaliseEmail(email: string): string;
|
|
82
|
+
//# sourceMappingURL=auth.service.d.ts.map
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The authentication flows (PLAN.md §15.1).
|
|
4
|
+
*
|
|
5
|
+
* The legacy endpoint surface is retained — `POST /auth/local`, `/auth/token`,
|
|
6
|
+
* `/auth/logout`, `/auth/password/forgot`, `/auth/password/reset`,
|
|
7
|
+
* `/auth/otp/send`, `/auth/otp/verify`, admin `/auth/user` — because breaking
|
|
8
|
+
* every existing client is not a security improvement. What changes is
|
|
9
|
+
* everything behind them.
|
|
10
|
+
*
|
|
11
|
+
* Two properties run through the whole file:
|
|
12
|
+
*
|
|
13
|
+
* **No enumeration.** Whether an email is registered is not something an
|
|
14
|
+
* unauthenticated caller may learn. A login against an unknown address still
|
|
15
|
+
* performs a hash verification so it takes the same time, and forgot-password
|
|
16
|
+
* answers identically either way.
|
|
17
|
+
*
|
|
18
|
+
* **Nothing partial.** A password reset revokes every session; a login records
|
|
19
|
+
* its own failures; a refresh either rotates or fails. There is no state where
|
|
20
|
+
* a credential is half-changed.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.AuthService = void 0;
|
|
24
|
+
exports.normaliseEmail = normaliseEmail;
|
|
25
|
+
const core_1 = require("@nage-api/core");
|
|
26
|
+
const password_js_1 = require("./password.js");
|
|
27
|
+
const jwt_js_1 = require("./jwt.js");
|
|
28
|
+
const config_js_1 = require("./config.js");
|
|
29
|
+
const lockout_service_js_1 = require("./lockout.service.js");
|
|
30
|
+
class AuthService {
|
|
31
|
+
#config;
|
|
32
|
+
#users;
|
|
33
|
+
#sessions;
|
|
34
|
+
#hasher;
|
|
35
|
+
#signer;
|
|
36
|
+
#otp;
|
|
37
|
+
#lockout;
|
|
38
|
+
#audit;
|
|
39
|
+
#clock;
|
|
40
|
+
/**
|
|
41
|
+
* Hash of a value nobody knows, verified against when the account does not
|
|
42
|
+
* exist. Without it, "unknown email" returns in a millisecond and "wrong
|
|
43
|
+
* password" in eighty — a timing oracle that enumerates the user table.
|
|
44
|
+
*/
|
|
45
|
+
#decoyHash;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.#config = options.config;
|
|
48
|
+
this.#users = options.users;
|
|
49
|
+
this.#sessions = options.sessions;
|
|
50
|
+
this.#hasher = options.hasher;
|
|
51
|
+
this.#signer = options.signer;
|
|
52
|
+
this.#otp = options.otp;
|
|
53
|
+
this.#lockout = options.lockout;
|
|
54
|
+
this.#audit = options.audit;
|
|
55
|
+
this.#clock = options.clock ?? { now: () => Date.now() };
|
|
56
|
+
}
|
|
57
|
+
/** `POST /auth/local` — email and password. */
|
|
58
|
+
async login(input) {
|
|
59
|
+
const email = normaliseEmail(input.email);
|
|
60
|
+
const key = lockout_service_js_1.LockoutService.key('login', email);
|
|
61
|
+
await this.#lockout.assertNotLocked(key);
|
|
62
|
+
const user = await this.#users.findByEmail(email);
|
|
63
|
+
const valid = await this.#verifyPassword(user, input.password);
|
|
64
|
+
if (!valid || user === undefined || user.disabled === true) {
|
|
65
|
+
const status = await this.#lockout.recordFailure(key, { email });
|
|
66
|
+
await this.#audit?.record({
|
|
67
|
+
name: 'login.failed',
|
|
68
|
+
at: this.#clock.now(),
|
|
69
|
+
...(user === undefined ? {} : { userId: user.id }),
|
|
70
|
+
meta: {
|
|
71
|
+
reason: user === undefined ? 'unknown-user' : 'bad-password',
|
|
72
|
+
failures: status.failures,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
// One error for every cause: wrong password, unknown address and disabled
|
|
76
|
+
// account are indistinguishable from outside.
|
|
77
|
+
throw new core_1.AuthenticationError('AUTH_INVALID_CREDENTIALS', {
|
|
78
|
+
detail: user === undefined
|
|
79
|
+
? `No user with email ${email}`
|
|
80
|
+
: `Password mismatch for user ${String(user.id)}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
await this.#lockout.recordSuccess(key);
|
|
84
|
+
// Upgrading opportunistically: the plaintext is only available here, and a
|
|
85
|
+
// parameter bump should not require a mass reset.
|
|
86
|
+
if (user.passwordHash !== undefined && this.#hasher.needsRehash(user.passwordHash)) {
|
|
87
|
+
await this.#users.setPasswordHash(user.id, await this.#hasher.hash(input.password));
|
|
88
|
+
}
|
|
89
|
+
return this.#issue(user, input.context ?? {}, 'login.succeeded');
|
|
90
|
+
}
|
|
91
|
+
/** `POST /auth/token` — exchange a refresh token, rotating it. */
|
|
92
|
+
async refresh(refreshToken, context = {}) {
|
|
93
|
+
const { session, refreshToken: rotated } = await this.#sessions.rotate(refreshToken, context);
|
|
94
|
+
const user = await this.#users.findById(session.userId);
|
|
95
|
+
if (user === undefined || user.disabled === true) {
|
|
96
|
+
// The account went away between issuing and refreshing; the family should
|
|
97
|
+
// not outlive it.
|
|
98
|
+
await this.#sessions.revokeAllForUser(session.userId, 'user-unavailable');
|
|
99
|
+
throw new core_1.AuthenticationError('AUTH_SESSION_REVOKED', {
|
|
100
|
+
detail: `Refresh for ${String(session.userId)} rejected: the user is missing or disabled`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return this.#tokenPair(user, session.familyId, rotated);
|
|
104
|
+
}
|
|
105
|
+
/** `POST /auth/logout` — revoke the whole session family. */
|
|
106
|
+
async logout(refreshToken) {
|
|
107
|
+
// Deliberately not reporting whether anything was revoked: a client
|
|
108
|
+
// logging out twice should see success both times.
|
|
109
|
+
await this.#sessions.revoke(refreshToken);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `POST /auth/password/forgot` — send a reset code.
|
|
113
|
+
*
|
|
114
|
+
* Returns nothing and never fails for an unknown address: the response is
|
|
115
|
+
* identical whether or not the account exists.
|
|
116
|
+
*/
|
|
117
|
+
async forgotPassword(email) {
|
|
118
|
+
const normalised = normaliseEmail(email);
|
|
119
|
+
const user = await this.#users.findByEmail(normalised);
|
|
120
|
+
if (user === undefined)
|
|
121
|
+
return;
|
|
122
|
+
await this.#otp.issue(normalised, 'password-reset');
|
|
123
|
+
}
|
|
124
|
+
/** `POST /auth/password/reset` — consume the code and set a new password. */
|
|
125
|
+
async resetPassword(input) {
|
|
126
|
+
const email = normaliseEmail(input.email);
|
|
127
|
+
const key = lockout_service_js_1.LockoutService.key('otp', email);
|
|
128
|
+
await this.#lockout.assertNotLocked(key);
|
|
129
|
+
(0, password_js_1.assertPasswordPolicy)(input.password, this.#config.password);
|
|
130
|
+
const user = await this.#users.findByEmail(email);
|
|
131
|
+
if (user === undefined) {
|
|
132
|
+
// Consume an attempt anyway, so a nonexistent address is not a free
|
|
133
|
+
// oracle for guessing codes.
|
|
134
|
+
await this.#lockout.recordFailure(key, { email });
|
|
135
|
+
throw new core_1.AuthenticationError('AUTH_OTP_INVALID', {
|
|
136
|
+
detail: `Password reset attempted for unknown email ${email}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await this.#otp.verify(email, 'password-reset', input.code);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
await this.#lockout.recordFailure(key, { email });
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
await this.#lockout.recordSuccess(key);
|
|
147
|
+
await this.#users.setPasswordHash(user.id, await this.#hasher.hash(input.password));
|
|
148
|
+
// Everything issued under the old password stops working. A reset is often
|
|
149
|
+
// a response to a compromise, and leaving live sessions defeats it.
|
|
150
|
+
await this.#sessions.revokeAllForUser(user.id, 'password-reset');
|
|
151
|
+
await this.#audit?.record({ name: 'password.reset', at: this.#clock.now(), userId: user.id });
|
|
152
|
+
}
|
|
153
|
+
/** `POST /auth/otp/send` — a login code. Silent for unknown addresses. */
|
|
154
|
+
async sendLoginOtp(email) {
|
|
155
|
+
const normalised = normaliseEmail(email);
|
|
156
|
+
const user = await this.#users.findByEmail(normalised);
|
|
157
|
+
if (user === undefined || user.disabled === true)
|
|
158
|
+
return;
|
|
159
|
+
await this.#otp.issue(normalised, 'login');
|
|
160
|
+
}
|
|
161
|
+
/** `POST /auth/otp/verify` — exchange a login code for tokens. */
|
|
162
|
+
async verifyLoginOtp(email, code, context = {}) {
|
|
163
|
+
const normalised = normaliseEmail(email);
|
|
164
|
+
const key = lockout_service_js_1.LockoutService.key('otp', normalised);
|
|
165
|
+
await this.#lockout.assertNotLocked(key);
|
|
166
|
+
const user = await this.#users.findByEmail(normalised);
|
|
167
|
+
if (user === undefined || user.disabled === true) {
|
|
168
|
+
await this.#lockout.recordFailure(key, { email: normalised });
|
|
169
|
+
throw new core_1.AuthenticationError('AUTH_OTP_INVALID', {
|
|
170
|
+
detail: `OTP login attempted for unknown or disabled account ${normalised}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await this.#otp.verify(normalised, 'login', code);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
await this.#lockout.recordFailure(key, { email: normalised });
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
await this.#lockout.recordSuccess(key);
|
|
181
|
+
return this.#issue(user, context, 'login.succeeded');
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Admin impersonation (`POST /auth/user`), retained from the legacy surface.
|
|
185
|
+
*
|
|
186
|
+
* The issued token records who is impersonating in an RFC 8693 `act` claim,
|
|
187
|
+
* so every downstream log line and audit row shows both identities. It is
|
|
188
|
+
* short-lived and cannot be chained: an impersonated session may not
|
|
189
|
+
* impersonate again.
|
|
190
|
+
*/
|
|
191
|
+
async impersonate(actor, targetUserId, context = {}) {
|
|
192
|
+
if (!this.#config.impersonation.enabled) {
|
|
193
|
+
throw new core_1.AuthorizationError('FORBIDDEN', {
|
|
194
|
+
detail: 'Impersonation is disabled; enable auth.impersonation to use it',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (actor.impersonatedBy !== undefined) {
|
|
198
|
+
throw new core_1.AuthorizationError('FORBIDDEN', {
|
|
199
|
+
detail: `Principal ${String(actor.id)} is already impersonating and may not chain`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const allowed = this.#config.impersonation.allowedRoles;
|
|
203
|
+
if (!actor.roles.some((role) => allowed.includes(role))) {
|
|
204
|
+
throw new core_1.AuthorizationError('INSUFFICIENT_ROLE', {
|
|
205
|
+
detail: `Impersonation needs one of [${allowed.join(', ')}]; ${String(actor.id)} has [${actor.roles.join(', ')}]`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const target = await this.#users.findById(targetUserId);
|
|
209
|
+
if (target === undefined || target.disabled === true) {
|
|
210
|
+
throw new core_1.AuthenticationError('AUTH_INVALID_CREDENTIALS', {
|
|
211
|
+
detail: `Impersonation target ${String(targetUserId)} is missing or disabled`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const { session, refreshToken } = await this.#sessions.issue(target.id, context);
|
|
215
|
+
await this.#audit?.record({
|
|
216
|
+
name: 'impersonation.started',
|
|
217
|
+
at: this.#clock.now(),
|
|
218
|
+
userId: target.id,
|
|
219
|
+
sessionId: session.familyId,
|
|
220
|
+
meta: { actorId: actor.id },
|
|
221
|
+
});
|
|
222
|
+
return this.#tokenPair(target, session.familyId, refreshToken, {
|
|
223
|
+
impersonatedBy: String(actor.id),
|
|
224
|
+
ttlSeconds: this.#config.impersonation.ttlSeconds,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async #issue(user, context, event) {
|
|
228
|
+
const { session, refreshToken } = await this.#sessions.issue(user.id, context);
|
|
229
|
+
await this.#audit?.record({
|
|
230
|
+
name: event,
|
|
231
|
+
at: this.#clock.now(),
|
|
232
|
+
userId: user.id,
|
|
233
|
+
sessionId: session.familyId,
|
|
234
|
+
});
|
|
235
|
+
return this.#tokenPair(user, session.familyId, refreshToken);
|
|
236
|
+
}
|
|
237
|
+
async #tokenPair(user, familyId, refreshToken, options = {}) {
|
|
238
|
+
const expiresIn = options.ttlSeconds ?? this.#config.jwt.accessTtlSeconds;
|
|
239
|
+
const accessToken = await this.#signer.sign((0, jwt_js_1.buildClaims)({
|
|
240
|
+
userId: String(user.id),
|
|
241
|
+
// The **family** id: it is the unit a logout or a reuse detection
|
|
242
|
+
// revokes, so it is what the access token must be checked against.
|
|
243
|
+
sessionId: familyId,
|
|
244
|
+
roles: user.roles,
|
|
245
|
+
permissions: (0, config_js_1.resolvePermissions)(this.#config.roles, user.roles, user.permissions ?? []),
|
|
246
|
+
...(user.tenantId === undefined ? {} : { tenantId: String(user.tenantId) }),
|
|
247
|
+
...(options.impersonatedBy === undefined ? {} : { impersonatedBy: options.impersonatedBy }),
|
|
248
|
+
}), { expiresInSeconds: expiresIn });
|
|
249
|
+
return { accessToken, refreshToken, expiresIn, tokenType: 'Bearer' };
|
|
250
|
+
}
|
|
251
|
+
async #verifyPassword(user, password) {
|
|
252
|
+
if (user?.passwordHash === undefined) {
|
|
253
|
+
// Burn the same work on a miss as on a hit.
|
|
254
|
+
await this.#hasher.verify(await this.#decoy(), password);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return this.#hasher.verify(user.passwordHash, password);
|
|
258
|
+
}
|
|
259
|
+
#decoy() {
|
|
260
|
+
this.#decoyHash ??= this.#hasher.hash((0, core_1.randomToken)(32));
|
|
261
|
+
return this.#decoyHash;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
exports.AuthService = AuthService;
|
|
265
|
+
/** Emails are compared case-insensitively; storing them normalised is the app's job. */
|
|
266
|
+
function normaliseEmail(email) {
|
|
267
|
+
return email.trim().toLowerCase();
|
|
268
|
+
}
|
|
269
|
+
//# sourceMappingURL=auth.service.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global authorization guard (PLAN.md §15.2).
|
|
3
|
+
*
|
|
4
|
+
* Runs after `AuthGuard`, so a principal is already in the context. Three
|
|
5
|
+
* checks, all of which must pass: any of the required roles, **all** of the
|
|
6
|
+
* required permissions, and the named policy.
|
|
7
|
+
*
|
|
8
|
+
* Permissions are conjunctive on purpose. `@Permissions('order:read',
|
|
9
|
+
* 'order:write')` on a handler that reads and writes should mean both; an
|
|
10
|
+
* "any" reading would grant write access to a read-only caller.
|
|
11
|
+
*/
|
|
12
|
+
import { type CanActivate, type ExecutionContext } from '@nestjs/common';
|
|
13
|
+
import { Reflector } from '@nestjs/core';
|
|
14
|
+
import type { PolicyRegistry } from './policy.js';
|
|
15
|
+
export interface AuthorizationGuardOptions {
|
|
16
|
+
readonly reflector: Reflector;
|
|
17
|
+
readonly policies: PolicyRegistry;
|
|
18
|
+
}
|
|
19
|
+
export declare class AuthorizationGuard implements CanActivate {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(options: AuthorizationGuardOptions);
|
|
22
|
+
canActivate(context: ExecutionContext): Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=authorization.guard.d.ts.map
|