@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
- /** Deletes every session of the user ("sign out everywhere"). */
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
  }
@@ -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
- return `otpauth://totp/${label}?${params}`;
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. The session's `extra` is
64
- * not carried over.
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
- /** Ends every session of a user, optionally keeping one (`except`: its id). */
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. The session's `extra` is
113
- * not carried over.
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
- /** Ends every session of a user, optionally keeping one (`except`: its id). */
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
- if (!except) {
151
- return store.deleteUserSessions(userId);
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 (const session of await store.listUserSessions(userId)) {
154
- if (session.id !== except) {
155
- await store.deleteSession(session.id);
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",
4
- "description": "Nest - modern, fast, powerful node.js web framework (@authentication)",
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.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.5",
96
+ "vite": "8.3.6",
82
97
  "vitest": "5.1.0",
83
98
  "ws": "8.22.1"
84
99
  },