@nestjs/authentication 0.0.1 → 0.1.1
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/README.md +64 -6
- package/dist/account/password-reset.service.d.ts +9 -3
- package/dist/account/password-reset.service.js +16 -4
- package/dist/authentication.constants.d.ts +9 -0
- package/dist/authentication.constants.js +9 -0
- package/dist/authentication.module-definition.js +13 -4
- package/dist/decorators/authenticate.decorator.js +4 -1
- package/dist/decorators/current-user.decorator.d.ts +3 -1
- package/dist/decorators/current-user.decorator.js +2 -0
- package/dist/decorators/public.decorator.d.ts +3 -0
- package/dist/decorators/public.decorator.js +3 -0
- package/dist/errors/authentication.error.d.ts +4 -2
- package/dist/errors/authentication.error.js +6 -2
- package/dist/errors/magic-link.error.d.ts +5 -4
- package/dist/errors/magic-link.error.js +6 -5
- package/dist/events/authentication-events.interface.d.ts +16 -1
- package/dist/events/authentication-events.service.d.ts +2 -1
- package/dist/events/authentication-events.service.js +2 -1
- package/dist/guards/authentication.guard.d.ts +4 -2
- package/dist/guards/authentication.guard.js +9 -6
- package/dist/index.d.ts +7 -2
- package/dist/interceptors/authentication-scope.interceptor.js +2 -2
- package/dist/interfaces/authentication-error-options.interface.d.ts +12 -2
- package/dist/interfaces/authentication-module-options.interface.d.ts +13 -1
- package/dist/interfaces/authentication-result.interface.d.ts +7 -0
- package/dist/interfaces/jwt-options.interface.d.ts +13 -7
- package/dist/interfaces/jwt.interface.d.ts +4 -3
- package/dist/interfaces/magic-link.interface.d.ts +2 -1
- package/dist/interfaces/oidc.interface.d.ts +9 -1
- package/dist/interfaces/session-store.interface.d.ts +29 -4
- package/dist/interfaces/token.interface.d.ts +31 -4
- package/dist/jwt/jwks.client.js +10 -3
- package/dist/jwt/jws.util.d.ts +10 -1
- package/dist/jwt/jws.util.js +108 -16
- package/dist/jwt/jwt-signer.service.js +2 -2
- package/dist/jwt/jwt-verifier.service.js +6 -4
- package/dist/jwt/token.service.d.ts +29 -8
- package/dist/jwt/token.service.js +48 -8
- package/dist/magic-link/magic-link.service.d.ts +4 -1
- package/dist/magic-link/magic-link.service.js +40 -10
- package/dist/mfa/mfa.service.d.ts +11 -1
- package/dist/mfa/mfa.service.js +19 -9
- package/dist/oidc/oidc.client.d.ts +1 -1
- package/dist/oidc/oidc.client.js +1 -1
- package/dist/oidc/oidc.service.d.ts +1 -0
- package/dist/oidc/oidc.service.js +22 -8
- package/dist/session/in-memory-session.store.d.ts +2 -0
- package/dist/session/in-memory-session.store.js +3 -1
- package/dist/session/session-cookie.provider.d.ts +17 -1
- package/dist/session/session-cookie.provider.js +16 -1
- package/dist/session/session.service.d.ts +18 -2
- package/dist/session/session.service.js +43 -8
- package/dist/session/sign-in.service.d.ts +6 -5
- package/dist/session/sign-in.service.js +6 -5
- package/dist/storage/authentication.storage.d.ts +25 -16
- package/dist/storage/authentication.storage.js +25 -16
- package/dist/testing/index.js +13 -5
- package/dist/utils/transport-errors.util.d.ts +23 -12
- package/dist/utils/transport-errors.util.js +34 -22
- package/package.json +28 -28
|
@@ -10,7 +10,8 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
10
10
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
11
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
12
|
};
|
|
13
|
-
|
|
13
|
+
var SessionService_1;
|
|
14
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
14
15
|
import { firstHeader } from '../utils/auth-state.util.js';
|
|
15
16
|
import { AUTHENTICATION_MODULE_OPTIONS } from '../authentication.constants.js';
|
|
16
17
|
import { isCrossOriginWrite, normalizeOrigin } from '../utils/cross-origin.util.js';
|
|
@@ -29,10 +30,12 @@ import { assertCookieAttributes, defaultCookieName, readCookie, serializeCookie
|
|
|
29
30
|
* - `create()`, `validate()` and `rotate()` are the low-level operations
|
|
30
31
|
* behind `SignInService`, which also reads and writes the cookie.
|
|
31
32
|
*/
|
|
32
|
-
let SessionService = class SessionService {
|
|
33
|
+
let SessionService = SessionService_1 = class SessionService {
|
|
33
34
|
constructor(storage, options, events = new AuthenticationEvents()) {
|
|
34
35
|
this.storage = storage;
|
|
35
36
|
this.events = events;
|
|
37
|
+
/** Touches failed since the last successful one: a spell of failures logs one warning, not one per request. */
|
|
38
|
+
this.failedTouches = 0;
|
|
36
39
|
this.options = options?.session ?? {};
|
|
37
40
|
this.absoluteTtl = durationOr(this.options.absoluteTtl, '7d');
|
|
38
41
|
// A pending session never outlives a verified one would.
|
|
@@ -70,7 +73,11 @@ let SessionService = class SessionService {
|
|
|
70
73
|
...(metadata && { metadata }),
|
|
71
74
|
});
|
|
72
75
|
}
|
|
73
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* The live session for a token, sliding its idle timeout. Sliding is
|
|
78
|
+
* best-effort: when the store fails to record the activity, the session
|
|
79
|
+
* is still returned, as read (see `SessionStore.touchSession()`).
|
|
80
|
+
*/
|
|
74
81
|
async validate(token) {
|
|
75
82
|
if (!token || !TOKEN_PATTERN.test(token)) {
|
|
76
83
|
return null;
|
|
@@ -90,8 +97,7 @@ let SessionService = class SessionService {
|
|
|
90
97
|
return null;
|
|
91
98
|
}
|
|
92
99
|
if (now - record.lastActiveAt.getTime() >= this.touchInterval) {
|
|
93
|
-
record
|
|
94
|
-
await this.storage.sessions.touchSession(record.id, record.lastActiveAt);
|
|
100
|
+
await this.touch(record, new Date(now));
|
|
95
101
|
}
|
|
96
102
|
return record;
|
|
97
103
|
}
|
|
@@ -103,10 +109,11 @@ let SessionService = class SessionService {
|
|
|
103
109
|
* `absoluteTtl` from its creation, in place of its `mfa.pendingTtl`.
|
|
104
110
|
* Resolves `null` when the session was gone by the time it was replaced:
|
|
105
111
|
* revoked (a sign-out everywhere, a password reset) or rotated by another
|
|
106
|
-
* request, which leaves no new session behind.
|
|
112
|
+
* request, which leaves no new session behind. The session's `extra` is
|
|
113
|
+
* not carried over.
|
|
107
114
|
*/
|
|
108
115
|
async rotate(session, changes = {}) {
|
|
109
|
-
const { id: _, ...rest } = session;
|
|
116
|
+
const { id: _, extra: _extra, ...rest } = session;
|
|
110
117
|
const verifying = session.mfa === 'pending' && changes.mfa === 'verified';
|
|
111
118
|
// The new session first, then the old one, which must still be there to delete: if a revocation
|
|
112
119
|
// took it meanwhile (deleting the user's sessions, this new one included, or not yet), or another
|
|
@@ -182,6 +189,33 @@ let SessionService = class SessionService {
|
|
|
182
189
|
async discard(sessionId) {
|
|
183
190
|
await this.storage.sessions.deleteSession(sessionId);
|
|
184
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Records activity, best-effort: the session was read live, so a failed write (a lock timeout,
|
|
194
|
+
* a read-only replica) does not fail the request, and the session keeps the idle deadline the
|
|
195
|
+
* store still has. Every failure is published as `session-touch-failed`; the log gets one warning
|
|
196
|
+
* per spell of failures (the first), then one line once a touch succeeds again, with how many
|
|
197
|
+
* failed in between.
|
|
198
|
+
*/
|
|
199
|
+
async touch(record, lastActiveAt) {
|
|
200
|
+
try {
|
|
201
|
+
await this.storage.sessions.touchSession(record.id, lastActiveAt);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (this.failedTouches++ === 0) {
|
|
205
|
+
SessionService_1.logger.warn(`Recording activity on a session of user ${record.userId} failed, so its idle timeout did not move: ` +
|
|
206
|
+
(error instanceof Error ? error.message : String(error)) +
|
|
207
|
+
'. Further failures are not logged until a touch succeeds again.');
|
|
208
|
+
}
|
|
209
|
+
// Published on every failure, logged or not, so metrics and alerting count them all.
|
|
210
|
+
this.events.emit({ type: 'session-touch-failed', userId: record.userId, sessionId: record.id, error });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
record.lastActiveAt = lastActiveAt;
|
|
214
|
+
if (this.failedTouches > 0) {
|
|
215
|
+
SessionService_1.logger.log(`Recording session activity works again; ${this.failedTouches} ${this.failedTouches === 1 ? 'touch' : 'touches'} failed meanwhile.`);
|
|
216
|
+
this.failedTouches = 0;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
185
219
|
isLive(record, now) {
|
|
186
220
|
if (hasExpired(record.expiresAt, now)) {
|
|
187
221
|
return false;
|
|
@@ -199,7 +233,8 @@ let SessionService = class SessionService {
|
|
|
199
233
|
return this.options.now?.() ?? Date.now();
|
|
200
234
|
}
|
|
201
235
|
};
|
|
202
|
-
SessionService =
|
|
236
|
+
SessionService.logger = new Logger('SessionService');
|
|
237
|
+
SessionService = SessionService_1 = __decorate([
|
|
203
238
|
Injectable(),
|
|
204
239
|
__param(1, Optional()),
|
|
205
240
|
__param(1, Inject(AUTHENTICATION_MODULE_OPTIONS)),
|
|
@@ -19,8 +19,9 @@ import { SessionService } from './session.service.js';
|
|
|
19
19
|
* (login CSRF: signing the victim's browser in to the attacker's
|
|
20
20
|
* account), by the same rule as `app.enableCsrfProtection()`;
|
|
21
21
|
* - the browser's previous session is deleted (session fixation defence);
|
|
22
|
-
* - users with a confirmed authenticator get an
|
|
23
|
-
* which is not signed in anywhere until
|
|
22
|
+
* - with `mfa` configured, users with a confirmed authenticator get an
|
|
23
|
+
* `mfa: 'pending'` session, which is not signed in anywhere until
|
|
24
|
+
* `completeMfa()` verifies a code.
|
|
24
25
|
*/
|
|
25
26
|
export declare class SignInService {
|
|
26
27
|
private readonly sessions;
|
|
@@ -71,9 +72,9 @@ export declare class SignInService {
|
|
|
71
72
|
*/
|
|
72
73
|
signOut(): Promise<boolean>;
|
|
73
74
|
/**
|
|
74
|
-
* Ends every session and refresh
|
|
75
|
-
* this browser's cookie if it
|
|
76
|
-
* table: revoke those yourself.
|
|
75
|
+
* Ends every session and, with refresh tokens on (`accessToken`), every
|
|
76
|
+
* refresh-token family of the user, and clears this browser's cookie if it
|
|
77
|
+
* was one of them. API keys live in your own table: revoke those yourself.
|
|
77
78
|
*/
|
|
78
79
|
signOutEverywhere(userId: string): Promise<void>;
|
|
79
80
|
/**
|
|
@@ -31,8 +31,9 @@ import { SessionService } from './session.service.js';
|
|
|
31
31
|
* (login CSRF: signing the victim's browser in to the attacker's
|
|
32
32
|
* account), by the same rule as `app.enableCsrfProtection()`;
|
|
33
33
|
* - the browser's previous session is deleted (session fixation defence);
|
|
34
|
-
* - users with a confirmed authenticator get an
|
|
35
|
-
* which is not signed in anywhere until
|
|
34
|
+
* - with `mfa` configured, users with a confirmed authenticator get an
|
|
35
|
+
* `mfa: 'pending'` session, which is not signed in anywhere until
|
|
36
|
+
* `completeMfa()` verifies a code.
|
|
36
37
|
*/
|
|
37
38
|
let SignInService = class SignInService {
|
|
38
39
|
constructor(sessions, mfa, tokens, scope = new AuthenticationScope(), adapterHost, events = new AuthenticationEvents()) {
|
|
@@ -162,9 +163,9 @@ let SignInService = class SignInService {
|
|
|
162
163
|
return true;
|
|
163
164
|
}
|
|
164
165
|
/**
|
|
165
|
-
* Ends every session and refresh
|
|
166
|
-
* this browser's cookie if it
|
|
167
|
-
* table: revoke those yourself.
|
|
166
|
+
* Ends every session and, with refresh tokens on (`accessToken`), every
|
|
167
|
+
* refresh-token family of the user, and clears this browser's cookie if it
|
|
168
|
+
* was one of them. API keys live in your own table: revoke those yourself.
|
|
168
169
|
*/
|
|
169
170
|
async signOutEverywhere(userId) {
|
|
170
171
|
const exchange = this.scope.exchange();
|
|
@@ -8,32 +8,41 @@ import type { AuthenticationStorageSources, AuthenticationStorageContract, Authe
|
|
|
8
8
|
/**
|
|
9
9
|
* @internal What each feature reads or writes, audited against the code
|
|
10
10
|
* paths: the production guard's rule.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* MFA is off unless `mfa` is configured: without it, `MfaService.isEnrolled()`
|
|
12
|
+
* answers `false` without reading the store, and its other methods throw.
|
|
13
|
+
* Refresh tokens exist only with `accessToken`, and not with
|
|
14
|
+
* `refreshToken: false`: otherwise `TokenService.issue()` starts no family
|
|
15
|
+
* and `revokeAll()` has nothing to revoke and reads nothing. Apps
|
|
16
|
+
* that sign users in from a shared user database must therefore agree: if
|
|
17
|
+
* one configures `mfa` (or `accessToken`), every one of them must, or a user
|
|
18
|
+
* who enrolled an authenticator through one signs in to another with a
|
|
19
|
+
* password alone (and a sign-out everywhere leaves token clients signed in).
|
|
20
|
+
* Removing `mfa` turns MFA off for everyone.
|
|
15
21
|
*/
|
|
16
22
|
export declare const CONTRACTS_BY_FEATURE: {
|
|
17
23
|
/**
|
|
18
24
|
* A `SessionCookieProvider` in `providers`: `SessionService` (every
|
|
19
|
-
* request), `SignInService.signIn()`
|
|
20
|
-
* and `signOutEverywhere()`, which also revokes the user's refresh-token
|
|
21
|
-
* families.
|
|
25
|
+
* request), `SignInService.signIn()` and `signOutEverywhere()`.
|
|
22
26
|
*/
|
|
23
|
-
readonly sessionCookie: readonly ["sessions"
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
readonly sessionCookie: readonly ["sessions"];
|
|
28
|
+
/**
|
|
29
|
+
* `accessToken` without `refreshToken: false`: the refresh-token families
|
|
30
|
+
* `TokenService` starts, which every sign-out everywhere revokes too.
|
|
31
|
+
* Access tokens alone keep no state.
|
|
32
|
+
*/
|
|
33
|
+
readonly refreshToken: readonly ["refreshTokens"];
|
|
34
|
+
/** `mfa`: enrollment, codes, recovery codes and the lockout; every sign-in checks for an authenticator. */
|
|
27
35
|
readonly mfa: readonly ["mfa"];
|
|
28
36
|
/** `magicLinkHandler`: pending links, then `SignInService.signIn()`. */
|
|
29
|
-
readonly magicLink: readonly ["sessions", "
|
|
37
|
+
readonly magicLink: readonly ["sessions", "magicLinks"];
|
|
30
38
|
/** `oidcAccountResolver`: logins in progress, the session that links an account, then `SignInService.signIn()`. */
|
|
31
|
-
readonly oidc: readonly ["sessions", "
|
|
39
|
+
readonly oidc: readonly ["sessions", "oidcStates"];
|
|
32
40
|
/**
|
|
33
|
-
* `passwordResetHandler`: the links; `reset()` revokes every session and
|
|
34
|
-
* refresh-token family of the user, and with
|
|
41
|
+
* `passwordResetHandler`: the links; `reset()` revokes every session (and
|
|
42
|
+
* refresh-token family, with refresh tokens on) of the user, and with
|
|
43
|
+
* `signIn: true` signs in.
|
|
35
44
|
*/
|
|
36
|
-
readonly passwordReset: readonly ["sessions", "
|
|
45
|
+
readonly passwordReset: readonly ["sessions", "emailTokens"];
|
|
37
46
|
/** `emailVerificationHandler`: the links. */
|
|
38
47
|
readonly emailVerification: readonly ["emailTokens"];
|
|
39
48
|
};
|
|
@@ -22,32 +22,41 @@ import { AUTHENTICATION_STORAGE_REQUIREMENTS } from '../authentication.constants
|
|
|
22
22
|
/**
|
|
23
23
|
* @internal What each feature reads or writes, audited against the code
|
|
24
24
|
* paths: the production guard's rule.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* MFA is off unless `mfa` is configured: without it, `MfaService.isEnrolled()`
|
|
26
|
+
* answers `false` without reading the store, and its other methods throw.
|
|
27
|
+
* Refresh tokens exist only with `accessToken`, and not with
|
|
28
|
+
* `refreshToken: false`: otherwise `TokenService.issue()` starts no family
|
|
29
|
+
* and `revokeAll()` has nothing to revoke and reads nothing. Apps
|
|
30
|
+
* that sign users in from a shared user database must therefore agree: if
|
|
31
|
+
* one configures `mfa` (or `accessToken`), every one of them must, or a user
|
|
32
|
+
* who enrolled an authenticator through one signs in to another with a
|
|
33
|
+
* password alone (and a sign-out everywhere leaves token clients signed in).
|
|
34
|
+
* Removing `mfa` turns MFA off for everyone.
|
|
29
35
|
*/
|
|
30
36
|
export const CONTRACTS_BY_FEATURE = {
|
|
31
37
|
/**
|
|
32
38
|
* A `SessionCookieProvider` in `providers`: `SessionService` (every
|
|
33
|
-
* request), `SignInService.signIn()`
|
|
34
|
-
* and `signOutEverywhere()`, which also revokes the user's refresh-token
|
|
35
|
-
* families.
|
|
39
|
+
* request), `SignInService.signIn()` and `signOutEverywhere()`.
|
|
36
40
|
*/
|
|
37
|
-
sessionCookie: ['sessions'
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
sessionCookie: ['sessions'],
|
|
42
|
+
/**
|
|
43
|
+
* `accessToken` without `refreshToken: false`: the refresh-token families
|
|
44
|
+
* `TokenService` starts, which every sign-out everywhere revokes too.
|
|
45
|
+
* Access tokens alone keep no state.
|
|
46
|
+
*/
|
|
47
|
+
refreshToken: ['refreshTokens'],
|
|
48
|
+
/** `mfa`: enrollment, codes, recovery codes and the lockout; every sign-in checks for an authenticator. */
|
|
41
49
|
mfa: ['mfa'],
|
|
42
50
|
/** `magicLinkHandler`: pending links, then `SignInService.signIn()`. */
|
|
43
|
-
magicLink: ['sessions', '
|
|
51
|
+
magicLink: ['sessions', 'magicLinks'],
|
|
44
52
|
/** `oidcAccountResolver`: logins in progress, the session that links an account, then `SignInService.signIn()`. */
|
|
45
|
-
oidc: ['sessions', '
|
|
53
|
+
oidc: ['sessions', 'oidcStates'],
|
|
46
54
|
/**
|
|
47
|
-
* `passwordResetHandler`: the links; `reset()` revokes every session and
|
|
48
|
-
* refresh-token family of the user, and with
|
|
55
|
+
* `passwordResetHandler`: the links; `reset()` revokes every session (and
|
|
56
|
+
* refresh-token family, with refresh tokens on) of the user, and with
|
|
57
|
+
* `signIn: true` signs in.
|
|
49
58
|
*/
|
|
50
|
-
passwordReset: ['sessions', '
|
|
59
|
+
passwordReset: ['sessions', 'emailTokens'],
|
|
51
60
|
/** `emailVerificationHandler`: the links. */
|
|
52
61
|
emailVerification: ['emailTokens'],
|
|
53
62
|
};
|
package/dist/testing/index.js
CHANGED
|
@@ -70,8 +70,8 @@ export function authenticationStoreContract(create, options = {}) {
|
|
|
70
70
|
for (const record of [full, bare, other]) {
|
|
71
71
|
await store.createSession(record);
|
|
72
72
|
}
|
|
73
|
-
same(await store.getSession(full.id), full, 'getSession() returns every field as saved');
|
|
74
|
-
same(await store.getSession(bare.id), bare, 'optional fields come back absent, not null');
|
|
73
|
+
same(stored(await store.getSession(full.id)), full, 'getSession() returns every field as saved');
|
|
74
|
+
same(stored(await store.getSession(bare.id)), bare, 'optional fields come back absent, not null');
|
|
75
75
|
same(await store.getSession(id()), undefined, 'getSession() of an unknown id');
|
|
76
76
|
same(sortIds(await store.listUserSessions(u1)), [full.id, bare.id].sort(), "listUserSessions() returns the user's sessions");
|
|
77
77
|
same(await store.listUserSessions(uid()), [], 'listUserSessions() of a user without sessions');
|
|
@@ -81,13 +81,13 @@ export function authenticationStoreContract(create, options = {}) {
|
|
|
81
81
|
same(await store.deleteSession(id()), false, 'deleteSession() of an unknown id resolves false');
|
|
82
82
|
await store.deleteUserSessions(u1);
|
|
83
83
|
same(await store.listUserSessions(u1), [], "deleteUserSessions() deletes the user's sessions");
|
|
84
|
-
same(await store.getSession(other.id), other, "deleteUserSessions() leaves other users' sessions alone");
|
|
84
|
+
same(stored(await store.getSession(other.id)), other, "deleteUserSessions() leaves other users' sessions alone");
|
|
85
85
|
});
|
|
86
86
|
add('sessions', 'touchSession() moves lastActiveAt forward only, and never recreates a deleted session', async ({ sessions: store }) => {
|
|
87
87
|
const record = session(uid(), { mfa: 'verified', metadata: { device: 'phone' } });
|
|
88
88
|
await store.createSession(record);
|
|
89
89
|
await store.touchSession(record.id, at(5_000));
|
|
90
|
-
same(await store.getSession(record.id), { ...record, lastActiveAt: at(5_000) }, 'touchSession() changes lastActiveAt, nothing else');
|
|
90
|
+
same(stored(await store.getSession(record.id)), { ...record, lastActiveAt: at(5_000) }, 'touchSession() changes lastActiveAt, nothing else');
|
|
91
91
|
await store.touchSession(record.id, at(1_000));
|
|
92
92
|
same((await store.getSession(record.id))?.lastActiveAt, at(5_000), 'touchSession() never moves lastActiveAt back');
|
|
93
93
|
await store.deleteSession(record.id);
|
|
@@ -102,7 +102,7 @@ export function authenticationStoreContract(create, options = {}) {
|
|
|
102
102
|
await store.createSession(idle);
|
|
103
103
|
await store.createSession(expired);
|
|
104
104
|
await store.createSession(session(user, { createdAt: at(2_000), lastActiveAt: at(2_000) }));
|
|
105
|
-
same(await store.getSession(idle.id), idle, 'a session idle for a long time, but within its absolute expiry, is kept');
|
|
105
|
+
same(stored(await store.getSession(idle.id)), idle, 'a session idle for a long time, but within its absolute expiry, is kept');
|
|
106
106
|
const left = await store.getSession(expired.id);
|
|
107
107
|
assert.ok(left === undefined || left.expiresAt <= at(2_000), 'a pruned session is gone, or still past its expiry');
|
|
108
108
|
});
|
|
@@ -514,6 +514,14 @@ function emailToken(overrides = {}) {
|
|
|
514
514
|
return { id: id(), purpose: 'password-reset', userId: uid(), email: `${uid()}@example.com`, createdAt: at(0), expiresAt: at(60_000), ...overrides };
|
|
515
515
|
}
|
|
516
516
|
const sortIds = (records) => records.map((record) => record.id).sort();
|
|
517
|
+
/** A session as stored: without the `extra` a store may read along with it. */
|
|
518
|
+
function stored(record) {
|
|
519
|
+
if (!record) {
|
|
520
|
+
return record;
|
|
521
|
+
}
|
|
522
|
+
const { extra: _, ...rest } = record;
|
|
523
|
+
return rest;
|
|
524
|
+
}
|
|
517
525
|
/** Deep equality where a missing key and an `undefined` one are the same, and `null` is not `undefined`. */
|
|
518
526
|
function same(actual, expected, message) {
|
|
519
527
|
assert.deepStrictEqual(normalize(actual), normalize(expected), message);
|
|
@@ -1,33 +1,44 @@
|
|
|
1
1
|
import { type ExecutionContext } from '@nestjs/common';
|
|
2
2
|
import type { HttpAdapterHost } from '@nestjs/core';
|
|
3
|
+
import type { AuthenticationError } from '../errors/authentication.error.js';
|
|
3
4
|
export interface Refusal {
|
|
4
5
|
/** Default 401. 403 for a user the route refuses (an unverified address), 409 for a conflict (`MfaAlreadyEnrolledError`). */
|
|
5
6
|
status?: 401 | 403 | 409;
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Without one, Nest's bare body (`new UnauthorizedException()`): the status
|
|
9
|
+
* phrase as `message`, and no `error`.
|
|
10
|
+
*/
|
|
7
11
|
message?: string;
|
|
8
|
-
/** Machine-readable reason (`mfa_required`)
|
|
12
|
+
/** Machine-readable reason (`mfa_required`): Nest's `errorCode`, and the body's `error` when there is a message. */
|
|
9
13
|
code?: string;
|
|
14
|
+
/** Machine-readable data the app chose to send (`AuthenticationError`'s `details`): the body's `details`. */
|
|
15
|
+
details?: Record<string, unknown>;
|
|
10
16
|
/** The error behind the refusal, kept as the exception's `cause` for logs. */
|
|
11
17
|
cause?: unknown;
|
|
12
18
|
}
|
|
19
|
+
/** The refusal for an `AuthenticationError` a provider or a handler threw. */
|
|
20
|
+
export declare function refusalOf(error: AuthenticationError): Refusal;
|
|
13
21
|
/**
|
|
14
22
|
* The one place this package's refusals become responses, for the guard and
|
|
15
23
|
* for `AuthenticationError`s thrown by handlers: the error each transport's
|
|
16
24
|
* exception filter understands, for the refusal's status.
|
|
17
25
|
*
|
|
18
26
|
* - HTTP and GraphQL: Nest's own `UnauthorizedException` (or
|
|
19
|
-
* `ForbiddenException` for a 403, `ConflictException` for a 409),
|
|
20
|
-
* `{"message":"Unauthorized","statusCode":401}`, or with a
|
|
21
|
-
* `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
|
|
22
|
-
* A `code`
|
|
23
|
-
* `
|
|
27
|
+
* `ForbiddenException` for a 403, `ConflictException` for a 409), with
|
|
28
|
+
* Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
|
|
29
|
+
* message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
|
|
30
|
+
* A `code` is Nest's `errorCode` (on the exception, and in the body), and
|
|
31
|
+
* the description in `error` when there is a message; `details` follow.
|
|
32
|
+
* Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
|
|
24
33
|
* - ws and rpc: a `WsException` / `RpcException` carrying
|
|
25
|
-
* `{ statusCode, message }` (plus `status: 'error'` for ws, and `error
|
|
26
|
-
*
|
|
27
|
-
* server error", hence their own classes,
|
|
28
|
-
* are installed.
|
|
34
|
+
* `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
|
|
35
|
+
* `errorCode` and `details` as above). Those filters report
|
|
36
|
+
* `HttpException`s as "Internal server error", hence their own classes,
|
|
37
|
+
* loaded only when the packages are installed.
|
|
38
|
+
*
|
|
39
|
+
* Nothing else reaches the body: `cause` stays on the exception, for logs.
|
|
29
40
|
*/
|
|
30
|
-
export declare function refusal(context: ExecutionContext, { status, message, code, cause }?: Refusal, { challenge, adapterHost }?: {
|
|
41
|
+
export declare function refusal(context: ExecutionContext, { status, message, code, details, cause }?: Refusal, { challenge, adapterHost }?: {
|
|
31
42
|
challenge?: string;
|
|
32
43
|
adapterHost?: HttpAdapterHost;
|
|
33
44
|
}): Promise<Error>;
|
|
@@ -10,25 +10,42 @@ function load(pkg, name) {
|
|
|
10
10
|
return pending;
|
|
11
11
|
}
|
|
12
12
|
const DEFAULT_MESSAGE = 'Unauthorized';
|
|
13
|
+
const PHRASES = { 401: 'Unauthorized', 403: 'Forbidden', 409: 'Conflict' };
|
|
14
|
+
/** The refusal for an `AuthenticationError` a provider or a handler threw. */
|
|
15
|
+
export function refusalOf(error) {
|
|
16
|
+
const { status, message, code, details } = error;
|
|
17
|
+
return { status, message, code, details, cause: error };
|
|
18
|
+
}
|
|
13
19
|
/**
|
|
14
20
|
* The one place this package's refusals become responses, for the guard and
|
|
15
21
|
* for `AuthenticationError`s thrown by handlers: the error each transport's
|
|
16
22
|
* exception filter understands, for the refusal's status.
|
|
17
23
|
*
|
|
18
24
|
* - HTTP and GraphQL: Nest's own `UnauthorizedException` (or
|
|
19
|
-
* `ForbiddenException` for a 403, `ConflictException` for a 409),
|
|
20
|
-
* `{"message":"Unauthorized","statusCode":401}`, or with a
|
|
21
|
-
* `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
|
|
22
|
-
* A `code`
|
|
23
|
-
* `
|
|
25
|
+
* `ForbiddenException` for a 403, `ConflictException` for a 409), with
|
|
26
|
+
* Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
|
|
27
|
+
* message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
|
|
28
|
+
* A `code` is Nest's `errorCode` (on the exception, and in the body), and
|
|
29
|
+
* the description in `error` when there is a message; `details` follow.
|
|
30
|
+
* Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
|
|
24
31
|
* - ws and rpc: a `WsException` / `RpcException` carrying
|
|
25
|
-
* `{ statusCode, message }` (plus `status: 'error'` for ws, and `error
|
|
26
|
-
*
|
|
27
|
-
* server error", hence their own classes,
|
|
28
|
-
* are installed.
|
|
32
|
+
* `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
|
|
33
|
+
* `errorCode` and `details` as above). Those filters report
|
|
34
|
+
* `HttpException`s as "Internal server error", hence their own classes,
|
|
35
|
+
* loaded only when the packages are installed.
|
|
36
|
+
*
|
|
37
|
+
* Nothing else reaches the body: `cause` stays on the exception, for logs.
|
|
29
38
|
*/
|
|
30
|
-
export async function refusal(context, { status = 401, message
|
|
31
|
-
|
|
39
|
+
export async function refusal(context, { status = 401, message, code, details, cause } = {}, { challenge, adapterHost } = {}) {
|
|
40
|
+
// The default message without a code gives the body of a bare `new UnauthorizedException()`.
|
|
41
|
+
const bare = message === undefined || (status === 401 && message === DEFAULT_MESSAGE && !code);
|
|
42
|
+
const payload = {
|
|
43
|
+
statusCode: status,
|
|
44
|
+
...(!bare && code && { error: code }),
|
|
45
|
+
message: message ?? PHRASES[status],
|
|
46
|
+
...(code && { errorCode: code }),
|
|
47
|
+
...(details !== undefined && { details }),
|
|
48
|
+
};
|
|
32
49
|
switch (context.getType()) {
|
|
33
50
|
case 'ws': {
|
|
34
51
|
const WsException = await load('@nestjs/websockets', 'WsException');
|
|
@@ -50,17 +67,12 @@ export async function refusal(context, { status = 401, message = DEFAULT_MESSAGE
|
|
|
50
67
|
}
|
|
51
68
|
break;
|
|
52
69
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
// The default message gives the body of a bare `new UnauthorizedException()`.
|
|
61
|
-
return message === DEFAULT_MESSAGE && !code
|
|
62
|
-
? new UnauthorizedException(undefined, options)
|
|
63
|
-
: new UnauthorizedException(message, options);
|
|
70
|
+
const Exception = status === 409 ? ConflictException : status === 403 ? ForbiddenException : UnauthorizedException;
|
|
71
|
+
// Nest builds the body: `error` is the code (the description) or the status phrase, then `errorCode`.
|
|
72
|
+
const options = { cause, ...(code && { errorCode: code, ...(!bare && { description: code }) }) };
|
|
73
|
+
const exception = new Exception(bare ? undefined : message, options);
|
|
74
|
+
// `details` are not Nest's: its body, with them.
|
|
75
|
+
return details === undefined ? exception : new Exception({ ...exception.getResponse(), details }, options);
|
|
64
76
|
}
|
|
65
77
|
function withCause(error, cause) {
|
|
66
78
|
if (cause !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs/authentication",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Nest - modern, fast, powerful node.js web framework (@authentication)",
|
|
5
5
|
"author": "Kamil Mysliwiec",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,41 +46,41 @@
|
|
|
46
46
|
"@electric-sql/pglite": "0.5.8",
|
|
47
47
|
"@electric-sql/pglite-socket": "0.2.11",
|
|
48
48
|
"@nestjs/apollo": "14.0.1",
|
|
49
|
-
"@nestjs/authorization": "
|
|
50
|
-
"@nestjs/common": "12.1.
|
|
51
|
-
"@nestjs/core": "12.1.
|
|
49
|
+
"@nestjs/authorization": "0.1.0",
|
|
50
|
+
"@nestjs/common": "12.1.2",
|
|
51
|
+
"@nestjs/core": "12.1.2",
|
|
52
52
|
"@nestjs/drizzle": "0.0.1",
|
|
53
53
|
"@nestjs/graphql": "14.0.1",
|
|
54
|
-
"@nestjs/idempotency": "0.0.
|
|
55
|
-
"@nestjs/mail": "
|
|
56
|
-
"@nestjs/microservices": "12.1.
|
|
57
|
-
"@nestjs/platform-express": "12.1.
|
|
58
|
-
"@nestjs/platform-fastify": "12.1.
|
|
59
|
-
"@nestjs/platform-ws": "12.1.
|
|
60
|
-
"@nestjs/resilience": "0.0.
|
|
61
|
-
"@nestjs/testing": "12.1.
|
|
62
|
-
"@nestjs/throttler": "6.7.
|
|
63
|
-
"@nestjs/websockets": "12.1.
|
|
64
|
-
"@types/node": "24.
|
|
54
|
+
"@nestjs/idempotency": "0.0.2",
|
|
55
|
+
"@nestjs/mail": "0.0.1",
|
|
56
|
+
"@nestjs/microservices": "12.1.2",
|
|
57
|
+
"@nestjs/platform-express": "12.1.2",
|
|
58
|
+
"@nestjs/platform-fastify": "12.1.2",
|
|
59
|
+
"@nestjs/platform-ws": "12.1.2",
|
|
60
|
+
"@nestjs/resilience": "0.0.2",
|
|
61
|
+
"@nestjs/testing": "12.1.2",
|
|
62
|
+
"@nestjs/throttler": "6.7.1",
|
|
63
|
+
"@nestjs/websockets": "12.1.2",
|
|
64
|
+
"@types/node": "24.19.2",
|
|
65
65
|
"@types/pg": "8.23.1",
|
|
66
|
-
"@types/supertest": "
|
|
67
|
-
"@types/ws": "8.18.
|
|
68
|
-
"drizzle-orm": "0.45.
|
|
66
|
+
"@types/supertest": "7.2.1",
|
|
67
|
+
"@types/ws": "8.18.2",
|
|
68
|
+
"drizzle-orm": "0.45.4",
|
|
69
69
|
"graphql": "16.14.2",
|
|
70
70
|
"husky": "9.1.7",
|
|
71
|
-
"lint-staged": "17.
|
|
72
|
-
"oxlint": "1.
|
|
73
|
-
"pg": "8.23.
|
|
74
|
-
"prettier": "3.9.
|
|
71
|
+
"lint-staged": "17.6.0",
|
|
72
|
+
"oxlint": "1.87.0",
|
|
73
|
+
"pg": "8.23.1",
|
|
74
|
+
"prettier": "3.9.10",
|
|
75
75
|
"reflect-metadata": "0.2.2",
|
|
76
|
-
"release-it": "21.1.
|
|
76
|
+
"release-it": "21.1.1",
|
|
77
77
|
"rxjs": "7.8.2",
|
|
78
|
-
"supertest": "7.
|
|
79
|
-
"typeorm": "1.1.
|
|
78
|
+
"supertest": "7.3.1",
|
|
79
|
+
"typeorm": "1.1.2",
|
|
80
80
|
"typescript": "7.0.2",
|
|
81
|
-
"vite": "8.3.
|
|
82
|
-
"vitest": "5.0.
|
|
83
|
-
"ws": "8.
|
|
81
|
+
"vite": "8.3.4",
|
|
82
|
+
"vitest": "5.0.3",
|
|
83
|
+
"ws": "8.22.0"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"@nestjs/common": "^12.0.0",
|