@nestjs/authentication 0.1.3 → 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.
- package/dist/context/authentication-scope.service.d.ts +3 -1
- package/dist/context/authentication-scope.service.js +5 -2
- package/dist/interfaces/session-store.interface.d.ts +10 -2
- package/dist/mfa/mfa.service.js +3 -1
- package/dist/session/session.service.d.ts +9 -3
- package/dist/session/session.service.js +46 -9
- package/dist/utils/auth-state.util.d.ts +3 -3
- package/dist/utils/auth-state.util.js +18 -13
- package/dist/utils/transport-errors.util.js +4 -0
- package/package.json +27 -12
|
@@ -36,7 +36,9 @@ export declare class AuthenticationScope {
|
|
|
36
36
|
* GraphQL resolver whose context carries `req` (and `res`), or an
|
|
37
37
|
* `@nestjs/mcp` call that arrived over HTTP (its context, the second of
|
|
38
38
|
* its arguments, carries `request`). An MCP call has no response of its own
|
|
39
|
-
* to set cookies on: the MCP SDK writes the HTTP response.
|
|
39
|
+
* to set cookies on: the MCP SDK writes the HTTP response. An `@nestjs/ai`
|
|
40
|
+
* agent function called in an HTTP request carries it the same way, and has
|
|
41
|
+
* no response either: the route that runs the agent owns it.
|
|
40
42
|
*/
|
|
41
43
|
exchange(): HttpExchange | undefined;
|
|
42
44
|
}
|
|
@@ -43,7 +43,9 @@ let AuthenticationScope = class AuthenticationScope {
|
|
|
43
43
|
* GraphQL resolver whose context carries `req` (and `res`), or an
|
|
44
44
|
* `@nestjs/mcp` call that arrived over HTTP (its context, the second of
|
|
45
45
|
* its arguments, carries `request`). An MCP call has no response of its own
|
|
46
|
-
* to set cookies on: the MCP SDK writes the HTTP response.
|
|
46
|
+
* to set cookies on: the MCP SDK writes the HTTP response. An `@nestjs/ai`
|
|
47
|
+
* agent function called in an HTTP request carries it the same way, and has
|
|
48
|
+
* no response either: the route that runs the agent owns it.
|
|
47
49
|
*/
|
|
48
50
|
exchange() {
|
|
49
51
|
const context = this.current?.context;
|
|
@@ -56,7 +58,8 @@ let AuthenticationScope = class AuthenticationScope {
|
|
|
56
58
|
const gql = context.getArgByIndex(2);
|
|
57
59
|
return gql?.req ? { request: gql.req, response: gql.res } : undefined;
|
|
58
60
|
}
|
|
59
|
-
case 'mcp':
|
|
61
|
+
case 'mcp':
|
|
62
|
+
case 'ai': {
|
|
60
63
|
const request = context.getArgByIndex(1)?.request;
|
|
61
64
|
return request?.headers ? { request } : undefined;
|
|
62
65
|
}
|
|
@@ -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
|
}
|
|
@@ -45,9 +45,9 @@ export declare function getRawResult(context: ExecutionContext): State | undefin
|
|
|
45
45
|
export declare function getRequestHeaders(context: ExecutionContext): Headers | undefined;
|
|
46
46
|
/**
|
|
47
47
|
* The request behind a call, as far as authentication needs it: headers,
|
|
48
|
-
* and the method for HTTP, GraphQL and
|
|
49
|
-
* so it counts as a safe request). `undefined` for RPC,
|
|
50
|
-
* stdio.
|
|
48
|
+
* and the method for HTTP, GraphQL, MCP and agent functions (a WebSocket
|
|
49
|
+
* handshake has none, so it counts as a safe request). `undefined` for RPC,
|
|
50
|
+
* for MCP over stdio, and for an agent function called in a job.
|
|
51
51
|
*/
|
|
52
52
|
export declare function requestOf(context: ExecutionContext): {
|
|
53
53
|
headers: Headers;
|
|
@@ -29,13 +29,14 @@ function callKeys(context) {
|
|
|
29
29
|
return keys;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* The context of an `@nestjs/mcp` call (a tool, resource or prompt)
|
|
33
|
-
* its arguments, `[input,
|
|
34
|
-
* dependency. Its `request` is the
|
|
32
|
+
* The context of an `@nestjs/mcp` call (a tool, resource or prompt) or of an
|
|
33
|
+
* `@nestjs/ai` agent function call, read from its arguments, `[input,
|
|
34
|
+
* context]`, so this package needs neither dependency. Its `request` is the
|
|
35
|
+
* HTTP platform's request: the one an MCP call arrived in (a plain
|
|
35
36
|
* `{ method, url, headers }` object for `@nestjs/mcp/testing`'s in-memory
|
|
36
|
-
* client),
|
|
37
|
+
* client), or the one the agent runs in. Absent over stdio, and in a job.
|
|
37
38
|
*/
|
|
38
|
-
function
|
|
39
|
+
function callContextOf(context) {
|
|
39
40
|
const call = context.getArgByIndex(1);
|
|
40
41
|
return typeof call === 'object' && call !== null ? call : undefined;
|
|
41
42
|
}
|
|
@@ -48,7 +49,9 @@ function mcpCallOf(context) {
|
|
|
48
49
|
* request it arrived in, else (over stdio) its own context object. Each MCP
|
|
49
50
|
* request travels in an HTTP request of its own, so the request is per call,
|
|
50
51
|
* and it is the one the endpoint's route was authenticated on: a guard there
|
|
51
|
-
* and on the call authenticate once.
|
|
52
|
+
* and on the call authenticate once. An agent function call (`ai`) is carried
|
|
53
|
+
* the same way: the HTTP request the agent runs in, which the route that runs
|
|
54
|
+
* it authenticated, else (in a job) its own context object.
|
|
52
55
|
*/
|
|
53
56
|
function carrierOf(context) {
|
|
54
57
|
switch (context.getType()) {
|
|
@@ -62,8 +65,9 @@ function carrierOf(context) {
|
|
|
62
65
|
}
|
|
63
66
|
case 'graphql':
|
|
64
67
|
return context.getArgByIndex(2)?.req;
|
|
65
|
-
case 'mcp':
|
|
66
|
-
|
|
68
|
+
case 'mcp':
|
|
69
|
+
case 'ai': {
|
|
70
|
+
const call = callContextOf(context);
|
|
67
71
|
return call?.request ?? call;
|
|
68
72
|
}
|
|
69
73
|
default:
|
|
@@ -206,9 +210,9 @@ export function getRequestHeaders(context) {
|
|
|
206
210
|
}
|
|
207
211
|
/**
|
|
208
212
|
* The request behind a call, as far as authentication needs it: headers,
|
|
209
|
-
* and the method for HTTP, GraphQL and
|
|
210
|
-
* so it counts as a safe request). `undefined` for RPC,
|
|
211
|
-
* stdio.
|
|
213
|
+
* and the method for HTTP, GraphQL, MCP and agent functions (a WebSocket
|
|
214
|
+
* handshake has none, so it counts as a safe request). `undefined` for RPC,
|
|
215
|
+
* for MCP over stdio, and for an agent function called in a job.
|
|
212
216
|
*/
|
|
213
217
|
export function requestOf(context) {
|
|
214
218
|
switch (context.getType()) {
|
|
@@ -221,8 +225,9 @@ export function requestOf(context) {
|
|
|
221
225
|
}
|
|
222
226
|
case 'graphql':
|
|
223
227
|
return context.getArgByIndex(2)?.req;
|
|
224
|
-
case 'mcp':
|
|
225
|
-
|
|
228
|
+
case 'mcp':
|
|
229
|
+
case 'ai': {
|
|
230
|
+
const request = callContextOf(context)?.request;
|
|
226
231
|
return request?.headers ? request : undefined;
|
|
227
232
|
}
|
|
228
233
|
default:
|
|
@@ -74,6 +74,10 @@ export async function refusal(context, { status = 401, message, code, details, c
|
|
|
74
74
|
// MCP SDK writes the HTTP response, so no challenge goes with it: an MCP client learns where to sign in from
|
|
75
75
|
// the endpoint's own 401 (`@nestjs/mcp`'s `auth` option).
|
|
76
76
|
break;
|
|
77
|
+
case 'ai':
|
|
78
|
+
// Nest's own exception too, which `@nestjs/ai` gives the model as the function's error. The route that runs
|
|
79
|
+
// the agent owns the HTTP response, so no challenge goes with it.
|
|
80
|
+
break;
|
|
77
81
|
}
|
|
78
82
|
const Exception = status === 409 ? ConflictException : status === 403 ? ForbiddenException : UnauthorizedException;
|
|
79
83
|
// Nest builds the body: `error` is the code (the description) or the status phrase, then `errorCode`.
|
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,21 +61,21 @@
|
|
|
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.
|
|
50
|
-
"@nestjs/common": "12.3.
|
|
51
|
-
"@nestjs/core": "12.3.
|
|
64
|
+
"@nestjs/authorization": "0.1.2",
|
|
65
|
+
"@nestjs/common": "12.3.3",
|
|
66
|
+
"@nestjs/core": "12.3.3",
|
|
52
67
|
"@nestjs/drizzle": "0.0.2",
|
|
53
68
|
"@nestjs/graphql": "14.0.1",
|
|
54
69
|
"@nestjs/idempotency": "0.0.3",
|
|
55
70
|
"@nestjs/mail": "0.0.1",
|
|
56
|
-
"@nestjs/microservices": "12.3.
|
|
57
|
-
"@nestjs/platform-express": "12.3.
|
|
58
|
-
"@nestjs/platform-fastify": "12.3.
|
|
59
|
-
"@nestjs/platform-ws": "12.3.
|
|
71
|
+
"@nestjs/microservices": "12.3.3",
|
|
72
|
+
"@nestjs/platform-express": "12.3.3",
|
|
73
|
+
"@nestjs/platform-fastify": "12.3.3",
|
|
74
|
+
"@nestjs/platform-ws": "12.3.3",
|
|
60
75
|
"@nestjs/resilience": "0.0.3",
|
|
61
|
-
"@nestjs/testing": "12.3.
|
|
76
|
+
"@nestjs/testing": "12.3.3",
|
|
62
77
|
"@nestjs/throttler": "6.7.1",
|
|
63
|
-
"@nestjs/websockets": "12.3.
|
|
78
|
+
"@nestjs/websockets": "12.3.3",
|
|
64
79
|
"@types/node": "24.19.2",
|
|
65
80
|
"@types/pg": "8.23.1",
|
|
66
81
|
"@types/supertest": "7.2.1",
|
|
@@ -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
|
},
|