@nestjs/authentication 0.0.1 → 0.1.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/README.md +64 -6
- package/dist/account/password-reset.service.d.ts +1 -1
- package/dist/account/password-reset.service.js +1 -1
- 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 +1 -1
- package/dist/errors/magic-link.error.js +1 -1
- 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/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/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/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 +25 -25
|
@@ -37,6 +37,12 @@ export declare class MfaService {
|
|
|
37
37
|
constructor(storage: AuthenticationStorage, options?: {
|
|
38
38
|
mfa?: MfaOptions;
|
|
39
39
|
}, events?: AuthenticationEvents);
|
|
40
|
+
/**
|
|
41
|
+
* Whether the user has a confirmed authenticator. Without the `mfa`
|
|
42
|
+
* option, MFA is off: `false`, without reading the store, even for a user
|
|
43
|
+
* who enrolled through another app sharing the user database. Every app
|
|
44
|
+
* that signs those users in must configure `mfa` if any of them does.
|
|
45
|
+
*/
|
|
40
46
|
isEnrolled(userId: string): Promise<boolean>;
|
|
41
47
|
/**
|
|
42
48
|
* Starts an enrollment. Throws {@link MfaAlreadyEnrolledError} when the user
|
|
@@ -97,7 +103,11 @@ export declare class MfaService {
|
|
|
97
103
|
private seal;
|
|
98
104
|
private open;
|
|
99
105
|
private matchingStep;
|
|
100
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Read at each call, never in the constructor: the registry locks once the
|
|
108
|
+
* module starts. Without the `mfa` option it throws before reading: MFA is
|
|
109
|
+
* off, and the production guard asks for no `MfaStore`.
|
|
110
|
+
*/
|
|
101
111
|
private get store();
|
|
102
112
|
private get maxAttempts();
|
|
103
113
|
private now;
|
package/dist/mfa/mfa.service.js
CHANGED
|
@@ -71,7 +71,7 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
71
71
|
this.options = options?.mfa;
|
|
72
72
|
this.lockoutWindow = durationOr(this.options?.lockoutWindow, '15m');
|
|
73
73
|
if (!this.options) {
|
|
74
|
-
return; // MFA
|
|
74
|
+
return; // MFA off: isEnrolled() answers false, everything else throws, and the store is never read
|
|
75
75
|
}
|
|
76
76
|
if (this.options.encryption === undefined) {
|
|
77
77
|
throw new Error('MFA: `mfa.encryption` is required. TOTP secrets cannot be hashed, so a store dump would let anyone ' +
|
|
@@ -86,7 +86,16 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
86
86
|
requireIntegerOption(this.options.window, 'mfa.window', { min: 0, max: 10 });
|
|
87
87
|
requireIntegerOption(this.options.recoveryCodes, 'mfa.recoveryCodes', { min: 1 });
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether the user has a confirmed authenticator. Without the `mfa`
|
|
91
|
+
* option, MFA is off: `false`, without reading the store, even for a user
|
|
92
|
+
* who enrolled through another app sharing the user database. Every app
|
|
93
|
+
* that signs those users in must configure `mfa` if any of them does.
|
|
94
|
+
*/
|
|
89
95
|
async isEnrolled(userId) {
|
|
96
|
+
if (!this.options) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
90
99
|
// Truthy, not `=== true`: a store that reads the flag back as `1` must not skip the second factor.
|
|
91
100
|
return !!(await this.store.getTotp(userId))?.confirmed;
|
|
92
101
|
}
|
|
@@ -211,7 +220,7 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
211
220
|
this.failed(userId, 'recovery-code', attempt);
|
|
212
221
|
return false;
|
|
213
222
|
}
|
|
214
|
-
remainingRecoveryCodes(userId) {
|
|
223
|
+
async remainingRecoveryCodes(userId) {
|
|
215
224
|
return this.store.countRecoveryCodes(userId);
|
|
216
225
|
}
|
|
217
226
|
/**
|
|
@@ -271,15 +280,9 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
271
280
|
this.events.emit({ type: 'mfa-failed', userId, method, failures: attempt, locked: attempt >= this.maxAttempts });
|
|
272
281
|
}
|
|
273
282
|
seal(userId, secret) {
|
|
274
|
-
if (!this.options) {
|
|
275
|
-
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
276
|
-
}
|
|
277
283
|
return this.cipher ? this.cipher.encrypt(secret, `totp.${userId}`) : secret;
|
|
278
284
|
}
|
|
279
285
|
open(userId, stored) {
|
|
280
|
-
if (!this.options) {
|
|
281
|
-
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
282
|
-
}
|
|
283
286
|
if (!this.cipher) {
|
|
284
287
|
return { plaintext: stored, needsReencrypt: false };
|
|
285
288
|
}
|
|
@@ -322,8 +325,15 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
322
325
|
}
|
|
323
326
|
return found;
|
|
324
327
|
}
|
|
325
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* Read at each call, never in the constructor: the registry locks once the
|
|
330
|
+
* module starts. Without the `mfa` option it throws before reading: MFA is
|
|
331
|
+
* off, and the production guard asks for no `MfaStore`.
|
|
332
|
+
*/
|
|
326
333
|
get store() {
|
|
334
|
+
if (!this.options) {
|
|
335
|
+
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
336
|
+
}
|
|
327
337
|
return this.storage.mfa;
|
|
328
338
|
}
|
|
329
339
|
get maxAttempts() {
|
|
@@ -5,7 +5,7 @@ import type { OidcTransaction, OidcClientOptions } from '../interfaces/oidc.inte
|
|
|
5
5
|
* PKCE (S256), `state` and `nonce`.
|
|
6
6
|
*
|
|
7
7
|
* Checks, in order: IdP error, `iss` response parameter (RFC 9207, mix-up
|
|
8
|
-
* defence), token response shape, ID token signature (JWKS, RS256/ES256,
|
|
8
|
+
* defence), token response shape, ID token signature (JWKS, RS256/ES256 by default,
|
|
9
9
|
* alg/key binding), `iss`, `aud`, `azp`, `exp`, `iat`, `nonce`, and that
|
|
10
10
|
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
11
11
|
*/
|
package/dist/oidc/oidc.client.js
CHANGED
|
@@ -29,7 +29,7 @@ function assertSecureUrl(value, what) {
|
|
|
29
29
|
* PKCE (S256), `state` and `nonce`.
|
|
30
30
|
*
|
|
31
31
|
* Checks, in order: IdP error, `iss` response parameter (RFC 9207, mix-up
|
|
32
|
-
* defence), token response shape, ID token signature (JWKS, RS256/ES256,
|
|
32
|
+
* defence), token response shape, ID token signature (JWKS, RS256/ES256 by default,
|
|
33
33
|
* alg/key binding), `iss`, `aud`, `azp`, `exp`, `iat`, `nonce`, and that
|
|
34
34
|
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
35
35
|
*/
|
|
@@ -15,6 +15,7 @@ export declare class InMemorySessionStore implements SessionStore {
|
|
|
15
15
|
lastActiveAt: Date;
|
|
16
16
|
mfa?: import("../index.js").MfaState;
|
|
17
17
|
metadata?: Record<string, unknown>;
|
|
18
|
+
extra?: import("../index.js").SessionExtra;
|
|
18
19
|
} | undefined>;
|
|
19
20
|
createSession(record: SessionRecord): Promise<void>;
|
|
20
21
|
touchSession(id: string, lastActiveAt: Date): Promise<void>;
|
|
@@ -27,6 +28,7 @@ export declare class InMemorySessionStore implements SessionStore {
|
|
|
27
28
|
lastActiveAt: Date;
|
|
28
29
|
mfa?: import("../index.js").MfaState;
|
|
29
30
|
metadata?: Record<string, unknown>;
|
|
31
|
+
extra?: import("../index.js").SessionExtra;
|
|
30
32
|
}[]>;
|
|
31
33
|
deleteUserSessions(userId: string): Promise<void>;
|
|
32
34
|
private sweep;
|
|
@@ -20,7 +20,9 @@ let InMemorySessionStore = class InMemorySessionStore {
|
|
|
20
20
|
return record && { ...record };
|
|
21
21
|
}
|
|
22
22
|
async createSession(record) {
|
|
23
|
-
|
|
23
|
+
// `extra` is read with a session, never stored.
|
|
24
|
+
const { extra: _, ...stored } = record;
|
|
25
|
+
this.sessions.set(record.id, stored);
|
|
24
26
|
// The new session's own clock, so tests with a fake one agree.
|
|
25
27
|
if (this.sessions.size >= this.sweepAt) {
|
|
26
28
|
this.sweep(record.lastActiveAt.getTime());
|
|
@@ -18,6 +18,19 @@ import { SessionService } from './session.service.js';
|
|
|
18
18
|
* }
|
|
19
19
|
* ```
|
|
20
20
|
*
|
|
21
|
+
* A store whose `getSession()` reads the user in the same query (a `JOIN`)
|
|
22
|
+
* can hand it over in `session.extra`, typed by `sessionExtra` on
|
|
23
|
+
* `AuthenticationTypes`, which saves the second read on every request:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* validate(session: SessionRecord) {
|
|
27
|
+
* return session.extra?.user ?? null;
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* `extra` stays out of the result: `@CurrentSession()`, `request.session`
|
|
32
|
+
* and `AuthenticationContext.session` get the session without it.
|
|
33
|
+
*
|
|
21
34
|
* A missing, stale or unknown cookie counts as "no credentials" rather than
|
|
22
35
|
* a 401: browsers keep sending them, and optional routes should keep
|
|
23
36
|
* working. So does the cookie on a cross-origin POST, PUT, PATCH or DELETE
|
|
@@ -28,7 +41,10 @@ import { SessionService } from './session.service.js';
|
|
|
28
41
|
*/
|
|
29
42
|
export declare abstract class SessionCookieProvider<TUser> extends AuthenticationProvider<TUser, SessionRecord> {
|
|
30
43
|
protected readonly sessions: SessionService;
|
|
31
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Loads the user for a live session; `null` for deleted or banned users, which ends the session.
|
|
46
|
+
* `session.extra` is what the store read with the session, if anything.
|
|
47
|
+
*/
|
|
32
48
|
protected abstract validate(session: SessionRecord): TUser | null | undefined | Promise<TUser | null | undefined>;
|
|
33
49
|
authenticate(context: ExecutionContext): Promise<AuthenticationResult<TUser, SessionRecord> | null>;
|
|
34
50
|
/** @internal An instance created with `new` was not property-injected. */
|
|
@@ -26,6 +26,19 @@ import { SessionService } from './session.service.js';
|
|
|
26
26
|
* }
|
|
27
27
|
* ```
|
|
28
28
|
*
|
|
29
|
+
* A store whose `getSession()` reads the user in the same query (a `JOIN`)
|
|
30
|
+
* can hand it over in `session.extra`, typed by `sessionExtra` on
|
|
31
|
+
* `AuthenticationTypes`, which saves the second read on every request:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* validate(session: SessionRecord) {
|
|
35
|
+
* return session.extra?.user ?? null;
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* `extra` stays out of the result: `@CurrentSession()`, `request.session`
|
|
40
|
+
* and `AuthenticationContext.session` get the session without it.
|
|
41
|
+
*
|
|
29
42
|
* A missing, stale or unknown cookie counts as "no credentials" rather than
|
|
30
43
|
* a 401: browsers keep sending them, and optional routes should keep
|
|
31
44
|
* working. So does the cookie on a cross-origin POST, PUT, PATCH or DELETE
|
|
@@ -45,7 +58,9 @@ export class SessionCookieProvider extends AuthenticationProvider {
|
|
|
45
58
|
await this.sessions.discard(session.id);
|
|
46
59
|
return null;
|
|
47
60
|
}
|
|
48
|
-
|
|
61
|
+
// What the store joined (often the user's row, password hash and all) is for `validate()` only.
|
|
62
|
+
const { extra: _, ...stored } = session;
|
|
63
|
+
return { user, session: stored, mfa: session.mfa };
|
|
49
64
|
}
|
|
50
65
|
/** @internal An instance created with `new` was not property-injected. */
|
|
51
66
|
[PROVIDER_INIT](resolve) {
|
|
@@ -18,6 +18,7 @@ type RequestHeaders = Record<string, string | string[] | undefined>;
|
|
|
18
18
|
export declare class SessionService {
|
|
19
19
|
private readonly storage;
|
|
20
20
|
private readonly events;
|
|
21
|
+
private static readonly logger;
|
|
21
22
|
private readonly options;
|
|
22
23
|
private readonly absoluteTtl;
|
|
23
24
|
private readonly pendingTtl;
|
|
@@ -25,6 +26,8 @@ export declare class SessionService {
|
|
|
25
26
|
private readonly touchInterval;
|
|
26
27
|
private readonly cookieName;
|
|
27
28
|
private readonly trustedOrigins;
|
|
29
|
+
/** Touches failed since the last successful one: a spell of failures logs one warning, not one per request. */
|
|
30
|
+
private failedTouches;
|
|
28
31
|
constructor(storage: AuthenticationStorage, options?: {
|
|
29
32
|
session?: SessionOptions;
|
|
30
33
|
mfa?: {
|
|
@@ -43,7 +46,11 @@ export declare class SessionService {
|
|
|
43
46
|
metadata?: Record<string, unknown>;
|
|
44
47
|
replacing?: string;
|
|
45
48
|
}): Promise<IssuedSession>;
|
|
46
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The live session for a token, sliding its idle timeout. Sliding is
|
|
51
|
+
* best-effort: when the store fails to record the activity, the session
|
|
52
|
+
* is still returned, as read (see `SessionStore.touchSession()`).
|
|
53
|
+
*/
|
|
47
54
|
validate(token: string | undefined): Promise<SessionRecord | null>;
|
|
48
55
|
/**
|
|
49
56
|
* Replaces the session id, keeping its user and absolute expiry. For
|
|
@@ -53,7 +60,8 @@ export declare class SessionService {
|
|
|
53
60
|
* `absoluteTtl` from its creation, in place of its `mfa.pendingTtl`.
|
|
54
61
|
* Resolves `null` when the session was gone by the time it was replaced:
|
|
55
62
|
* revoked (a sign-out everywhere, a password reset) or rotated by another
|
|
56
|
-
* request, which leaves no new session behind.
|
|
63
|
+
* request, which leaves no new session behind. The session's `extra` is
|
|
64
|
+
* not carried over.
|
|
57
65
|
*/
|
|
58
66
|
rotate(session: SessionRecord, changes?: Pick<Partial<SessionRecord>, 'mfa' | 'metadata'>): Promise<IssuedSession | null>;
|
|
59
67
|
/**
|
|
@@ -90,6 +98,14 @@ export declare class SessionService {
|
|
|
90
98
|
clearCookie(): string;
|
|
91
99
|
/** @internal Deletes a session by id, for sessions whose user is gone. */
|
|
92
100
|
discard(sessionId: string): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Records activity, best-effort: the session was read live, so a failed write (a lock timeout,
|
|
103
|
+
* a read-only replica) does not fail the request, and the session keeps the idle deadline the
|
|
104
|
+
* store still has. Every failure is published as `session-touch-failed`; the log gets one warning
|
|
105
|
+
* per spell of failures (the first), then one line once a touch succeeds again, with how many
|
|
106
|
+
* failed in between.
|
|
107
|
+
*/
|
|
108
|
+
private touch;
|
|
93
109
|
private isLive;
|
|
94
110
|
private issue;
|
|
95
111
|
private now;
|
|
@@ -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);
|