@nestjs/authentication 0.1.4 → 0.1.5
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.
|
@@ -70,9 +70,17 @@ export interface SessionStore {
|
|
|
70
70
|
deleteSession(id: string): Promise<boolean>;
|
|
71
71
|
/**
|
|
72
72
|
* The user's sessions, in any order (`[]` when none), expired ones included.
|
|
73
|
-
* Without `extra`: they feed "your sessions" pages
|
|
73
|
+
* Without `extra`: they feed "your sessions" pages, and
|
|
74
|
+
* `SessionService.revokeAll()` reads them back after it deletes, so read
|
|
75
|
+
* what was just written and deleted: the primary, not a replica that lags.
|
|
74
76
|
*/
|
|
75
77
|
listUserSessions(userId: string): Promise<SessionRecord[]>;
|
|
76
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Deletes every session of the user ("sign out everywhere"): a plain
|
|
80
|
+
* `DELETE … WHERE user_id = ?`. It may miss a session a rotation writes
|
|
81
|
+
* while it runs (at Read Committed, its snapshot predates the new row), so
|
|
82
|
+
* `SessionService.revokeAll()` reads the user's sessions back with
|
|
83
|
+
* `listUserSessions()` and deletes again until none is left.
|
|
84
|
+
*/
|
|
77
85
|
deleteUserSessions(userId: string): Promise<void>;
|
|
78
86
|
}
|
package/dist/mfa/mfa.service.js
CHANGED
|
@@ -238,7 +238,9 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
238
238
|
const issuer = this.options?.issuer ?? 'NestJS';
|
|
239
239
|
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`;
|
|
240
240
|
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: String(DIGITS), period: String(PERIOD) });
|
|
241
|
-
|
|
241
|
+
// The key URI format encodes as RFC 3986, where `+` is a plus sign: a space is `%20`, as in the label.
|
|
242
|
+
// URLSearchParams writes a space as `+`, and a literal `+` as `%2B`, so each `+` left is a space.
|
|
243
|
+
return `otpauth://totp/${label}?${params.toString().replaceAll('+', '%20')}`;
|
|
242
244
|
}
|
|
243
245
|
/** `opened` is undefined when the secret could not be decrypted: fail closed, not counted against the user. */
|
|
244
246
|
async check(userId, opened, code) {
|
|
@@ -60,8 +60,9 @@ export declare class SessionService {
|
|
|
60
60
|
* `absoluteTtl` from its creation, in place of its `mfa.pendingTtl`.
|
|
61
61
|
* Resolves `null` when the session was gone by the time it was replaced:
|
|
62
62
|
* revoked (a sign-out everywhere, a password reset) or rotated by another
|
|
63
|
-
* request, which leaves no new session behind.
|
|
64
|
-
*
|
|
63
|
+
* request, which leaves no new session behind. A rotation that wins while
|
|
64
|
+
* `revokeAll()` is under way still resolves its new session, which that
|
|
65
|
+
* `revokeAll()` then ends. The session's `extra` is not carried over.
|
|
65
66
|
*/
|
|
66
67
|
rotate(session: SessionRecord, changes?: Pick<Partial<SessionRecord>, 'mfa' | 'metadata'>): Promise<IssuedSession | null>;
|
|
67
68
|
/**
|
|
@@ -72,7 +73,12 @@ export declare class SessionService {
|
|
|
72
73
|
revoke(sessionId: string, { userId }: {
|
|
73
74
|
userId: string;
|
|
74
75
|
}): Promise<boolean>;
|
|
75
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Ends every session of a user, optionally keeping one (`except`: its id).
|
|
78
|
+
* Resolves once a read of the user's sessions finds none left but the kept
|
|
79
|
+
* one, and rejects when the store still lists others after five rounds of
|
|
80
|
+
* deletes.
|
|
81
|
+
*/
|
|
76
82
|
revokeAll(userId: string, { except }?: {
|
|
77
83
|
except?: string;
|
|
78
84
|
}): Promise<void>;
|
|
@@ -20,6 +20,12 @@ import { durationOr, hasExpired } from '../utils/duration.util.js';
|
|
|
20
20
|
import { AuthenticationEvents } from '../events/authentication-events.service.js';
|
|
21
21
|
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
22
22
|
import { assertCookieAttributes, defaultCookieName, readCookie, serializeCookie } from './cookies.util.js';
|
|
23
|
+
/**
|
|
24
|
+
* The rounds of deletes `revokeAll()` makes before it gives up. A round past the first takes a
|
|
25
|
+
* rotation that won during the one before; more than a few take a store that keeps listing what it
|
|
26
|
+
* deleted.
|
|
27
|
+
*/
|
|
28
|
+
const REVOKE_ROUNDS = 5;
|
|
23
29
|
/**
|
|
24
30
|
* Server-side sessions: opaque 256-bit tokens in an `HttpOnly` cookie,
|
|
25
31
|
* stored by their SHA-256.
|
|
@@ -109,15 +115,17 @@ let SessionService = SessionService_1 = class SessionService {
|
|
|
109
115
|
* `absoluteTtl` from its creation, in place of its `mfa.pendingTtl`.
|
|
110
116
|
* Resolves `null` when the session was gone by the time it was replaced:
|
|
111
117
|
* revoked (a sign-out everywhere, a password reset) or rotated by another
|
|
112
|
-
* request, which leaves no new session behind.
|
|
113
|
-
*
|
|
118
|
+
* request, which leaves no new session behind. A rotation that wins while
|
|
119
|
+
* `revokeAll()` is under way still resolves its new session, which that
|
|
120
|
+
* `revokeAll()` then ends. The session's `extra` is not carried over.
|
|
114
121
|
*/
|
|
115
122
|
async rotate(session, changes = {}) {
|
|
116
123
|
const { id: _, extra: _extra, ...rest } = session;
|
|
117
124
|
const verifying = session.mfa === 'pending' && changes.mfa === 'verified';
|
|
118
125
|
// The new session first, then the old one, which must still be there to delete: if a revocation
|
|
119
126
|
// took it meanwhile (deleting the user's sessions, this new one included, or not yet), or another
|
|
120
|
-
// rotation did, this one lost, and its new session must not outlive the old.
|
|
127
|
+
// rotation did, this one lost, and its new session must not outlive the old. A revocation still
|
|
128
|
+
// under way when this one wins reads the user's sessions back, and ends the new one.
|
|
121
129
|
const issued = await this.issue({
|
|
122
130
|
...rest,
|
|
123
131
|
...changes,
|
|
@@ -144,15 +152,44 @@ let SessionService = SessionService_1 = class SessionService {
|
|
|
144
152
|
this.events.emit({ type: 'sign-out', userId, sessionId: record.id });
|
|
145
153
|
return true;
|
|
146
154
|
}
|
|
147
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* Ends every session of a user, optionally keeping one (`except`: its id).
|
|
157
|
+
* Resolves once a read of the user's sessions finds none left but the kept
|
|
158
|
+
* one, and rejects when the store still lists others after five rounds of
|
|
159
|
+
* deletes.
|
|
160
|
+
*/
|
|
148
161
|
async revokeAll(userId, { except } = {}) {
|
|
149
162
|
const store = this.storage.sessions;
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
const others = async () => (await store.listUserSessions(userId)).filter((session) => session.id !== except);
|
|
164
|
+
// A rotation writes its new session, then deletes the old one. One that does both while the
|
|
165
|
+
// delete below is under way wins (the old session was still there), and the delete can miss its
|
|
166
|
+
// new session: a list read before it was written, or one `DELETE … WHERE user_id = ?` at
|
|
167
|
+
// PostgreSQL's Read Committed, whose snapshot predates the new row and which skips the old one
|
|
168
|
+
// the rotation deleted meanwhile. That `DELETE` removes nothing, so its row count can't tell.
|
|
169
|
+
// Instead, a statement of its own reads the sessions back, and the deletes repeat until it finds
|
|
170
|
+
// none. A read that finds none has seen every rotation that won against this revocation: each
|
|
171
|
+
// wrote its new session before it deleted the old one, which was gone by the time of that read.
|
|
172
|
+
let left = except ? await others() : [];
|
|
173
|
+
if (except && left.length === 0) {
|
|
174
|
+
return;
|
|
152
175
|
}
|
|
153
|
-
for (
|
|
154
|
-
if (
|
|
155
|
-
|
|
176
|
+
for (let round = 1;; round++) {
|
|
177
|
+
if (except) {
|
|
178
|
+
for (const session of left) {
|
|
179
|
+
await store.deleteSession(session.id);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
await store.deleteUserSessions(userId);
|
|
184
|
+
}
|
|
185
|
+
left = await others();
|
|
186
|
+
if (left.length === 0) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (round === REVOKE_ROUNDS) {
|
|
190
|
+
throw new Error(`SessionService.revokeAll(): ${left.length} ${left.length === 1 ? 'session' : 'sessions'} of user ${userId} ` +
|
|
191
|
+
`still listed after ${REVOKE_ROUNDS} rounds of deletes. listUserSessions() must read what was just deleted ` +
|
|
192
|
+
'(the primary, not a replica that lags), and deleteUserSessions() must delete every session of the user.');
|
|
156
193
|
}
|
|
157
194
|
}
|
|
158
195
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs/authentication",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Authentication for NestJS: sign-in with passwords, Google and magic links, server-side sessions, JWT bearer tokens, two-factor and API keys.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nestjs",
|
|
7
|
+
"nest",
|
|
8
|
+
"authentication",
|
|
9
|
+
"auth",
|
|
10
|
+
"login",
|
|
11
|
+
"sign-in",
|
|
12
|
+
"session",
|
|
13
|
+
"jwt",
|
|
14
|
+
"oauth",
|
|
15
|
+
"magic-link",
|
|
16
|
+
"two-factor",
|
|
17
|
+
"2fa",
|
|
18
|
+
"api-keys"
|
|
19
|
+
],
|
|
5
20
|
"author": "Kamil Mysliwiec",
|
|
6
21
|
"license": "MIT",
|
|
7
22
|
"url": "https://github.com/nestjs/authentication#readme",
|
|
@@ -46,7 +61,7 @@
|
|
|
46
61
|
"@electric-sql/pglite": "0.5.8",
|
|
47
62
|
"@electric-sql/pglite-socket": "0.2.11",
|
|
48
63
|
"@nestjs/apollo": "14.0.1",
|
|
49
|
-
"@nestjs/authorization": "0.1.
|
|
64
|
+
"@nestjs/authorization": "0.1.2",
|
|
50
65
|
"@nestjs/common": "12.3.3",
|
|
51
66
|
"@nestjs/core": "12.3.3",
|
|
52
67
|
"@nestjs/drizzle": "0.0.2",
|
|
@@ -78,7 +93,7 @@
|
|
|
78
93
|
"supertest": "7.3.1",
|
|
79
94
|
"typeorm": "1.1.2",
|
|
80
95
|
"typescript": "7.0.2",
|
|
81
|
-
"vite": "8.3.
|
|
96
|
+
"vite": "8.3.6",
|
|
82
97
|
"vitest": "5.1.0",
|
|
83
98
|
"ws": "8.22.1"
|
|
84
99
|
},
|