@nestjs/authentication 0.1.1 → 0.1.3
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 +1 -1
- package/SECURITY.md +13 -0
- package/dist/account/email-verification.service.d.ts +3 -1
- package/dist/account/email-verification.service.js +18 -5
- package/dist/account/password-reset.service.d.ts +8 -1
- package/dist/account/password-reset.service.js +37 -13
- package/dist/context/authentication-scope.service.d.ts +5 -2
- package/dist/context/authentication-scope.service.js +9 -2
- package/dist/interfaces/email-token-store.interface.d.ts +3 -1
- package/dist/interfaces/oidc.interface.d.ts +21 -1
- package/dist/magic-link/magic-link.service.d.ts +4 -1
- package/dist/magic-link/magic-link.service.js +7 -1
- package/dist/oidc/oidc.client.d.ts +30 -4
- package/dist/oidc/oidc.client.js +70 -36
- package/dist/oidc/oidc.service.d.ts +8 -3
- package/dist/oidc/oidc.service.js +48 -21
- package/dist/services/password-hasher.service.d.ts +8 -1
- package/dist/services/password-hasher.service.js +21 -1
- package/dist/testing/index.js +12 -10
- package/dist/utils/auth-state.util.d.ts +5 -2
- package/dist/utils/auth-state.util.js +29 -3
- package/dist/utils/transport-errors.util.d.ts +3 -1
- package/dist/utils/transport-errors.util.js +9 -1
- package/package.json +19 -19
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
## Description
|
|
20
20
|
|
|
21
|
-
Authentication module for [Nest](https://github.com/nestjs/nest): a global guard with `@Public()` and `@Authenticate()`, credential providers as ordinary injectable classes (bearer JWT, cookie sessions, your own), server-side sessions with rotation, refresh tokens, TOTP second factor, magic links, OIDC sign-in, email verification and password reset, across HTTP, GraphQL, WebSockets and
|
|
21
|
+
Authentication module for [Nest](https://github.com/nestjs/nest): a global guard with `@Public()` and `@Authenticate()`, credential providers as ordinary injectable classes (bearer JWT, cookie sessions, your own), server-side sessions with rotation, refresh tokens, TOTP second factor, magic links, OIDC sign-in, email verification and password reset, across HTTP, GraphQL, WebSockets, microservices and MCP tool calls (`@nestjs/mcp`), with no third-party dependencies.
|
|
22
22
|
|
|
23
23
|
## Installation
|
|
24
24
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
Please report security issues to `support@nestjs.com` rather than in a public issue or pull request.
|
|
6
|
+
|
|
7
|
+
A good report includes:
|
|
8
|
+
|
|
9
|
+
- the version of `@nestjs/authentication` you found it in, and of Node.js,
|
|
10
|
+
- the steps or a short script that reproduce it,
|
|
11
|
+
- what an attacker could do with it.
|
|
12
|
+
|
|
13
|
+
Please give us a chance to release a fix before you disclose the issue publicly.
|
|
@@ -14,6 +14,7 @@ export declare class EmailVerificationService {
|
|
|
14
14
|
private readonly storage;
|
|
15
15
|
private readonly registry;
|
|
16
16
|
private readonly events;
|
|
17
|
+
private static readonly logger;
|
|
17
18
|
private readonly options?;
|
|
18
19
|
private readonly ttl;
|
|
19
20
|
constructor(storage: AuthenticationStorage, registry: AuthenticationRegistry, options?: {
|
|
@@ -34,7 +35,8 @@ export declare class EmailVerificationService {
|
|
|
34
35
|
* Burns the token and asks `EmailVerificationHandler.markVerified()` to
|
|
35
36
|
* record the address, if it is still the user's. Resolves the verified
|
|
36
37
|
* address, or `null` for unknown, used or expired tokens and for an
|
|
37
|
-
* address the user has since changed.
|
|
38
|
+
* address the user has since changed. When `markVerified()` throws, the
|
|
39
|
+
* link works again until it expires, and the error is rethrown.
|
|
38
40
|
*/
|
|
39
41
|
verify(token: string): Promise<{
|
|
40
42
|
userId: string;
|
|
@@ -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 EmailVerificationService_1;
|
|
14
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
14
15
|
import { AuthenticationRegistry } from '../services/authentication-registry.service.js';
|
|
15
16
|
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
16
17
|
import { AUTHENTICATION_MODULE_OPTIONS } from '../authentication.constants.js';
|
|
@@ -26,7 +27,7 @@ import { requireUrlOption } from '../utils/options.util.js';
|
|
|
26
27
|
* Point the link at a page that POSTs the token back: mail scanners and link
|
|
27
28
|
* previews follow GET links, and would use it up.
|
|
28
29
|
*/
|
|
29
|
-
let EmailVerificationService = class EmailVerificationService {
|
|
30
|
+
let EmailVerificationService = EmailVerificationService_1 = class EmailVerificationService {
|
|
30
31
|
constructor(storage, registry, options, events = new AuthenticationEvents()) {
|
|
31
32
|
this.storage = storage;
|
|
32
33
|
this.registry = registry;
|
|
@@ -70,7 +71,8 @@ let EmailVerificationService = class EmailVerificationService {
|
|
|
70
71
|
* Burns the token and asks `EmailVerificationHandler.markVerified()` to
|
|
71
72
|
* record the address, if it is still the user's. Resolves the verified
|
|
72
73
|
* address, or `null` for unknown, used or expired tokens and for an
|
|
73
|
-
* address the user has since changed.
|
|
74
|
+
* address the user has since changed. When `markVerified()` throws, the
|
|
75
|
+
* link works again until it expires, and the error is rethrown.
|
|
74
76
|
*/
|
|
75
77
|
async verify(token) {
|
|
76
78
|
const { handler } = this.feature();
|
|
@@ -81,7 +83,17 @@ let EmailVerificationService = class EmailVerificationService {
|
|
|
81
83
|
if (!record || hasExpired(record.expiresAt, this.now())) {
|
|
82
84
|
return null;
|
|
83
85
|
}
|
|
84
|
-
|
|
86
|
+
let verified;
|
|
87
|
+
try {
|
|
88
|
+
verified = await handler.markVerified(record.userId, record.email);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// Give the link back, so the user can try it again rather than ask for another email. Marking
|
|
92
|
+
// an address verified twice changes nothing, and one the user changed since gets `false`.
|
|
93
|
+
await this.storage.emailTokens.saveEmailToken(record).catch((saveError) => EmailVerificationService_1.logger.error('Could not give back an email verification link', saveError instanceof Error ? saveError.stack : saveError));
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (!verified) {
|
|
85
97
|
return null;
|
|
86
98
|
}
|
|
87
99
|
await this.storage.emailTokens.deleteUserEmailTokens(record.userId, 'email-verification');
|
|
@@ -101,7 +113,8 @@ let EmailVerificationService = class EmailVerificationService {
|
|
|
101
113
|
return this.options?.now?.() ?? Date.now();
|
|
102
114
|
}
|
|
103
115
|
};
|
|
104
|
-
EmailVerificationService =
|
|
116
|
+
EmailVerificationService.logger = new Logger('EmailVerificationService');
|
|
117
|
+
EmailVerificationService = EmailVerificationService_1 = __decorate([
|
|
105
118
|
Injectable(),
|
|
106
119
|
__param(2, Optional()),
|
|
107
120
|
__param(2, Inject(AUTHENTICATION_MODULE_OPTIONS)),
|
|
@@ -62,11 +62,18 @@ export declare class PasswordResetService implements OnModuleDestroy {
|
|
|
62
62
|
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
63
63
|
* `ForbiddenException` first, as `SignInService.signIn()` does, so the link
|
|
64
64
|
* and the password stay as they were. Without `signIn`, the origin is not
|
|
65
|
-
* checked.
|
|
65
|
+
* checked. When a step throws before the new password is stored (the
|
|
66
|
+
* lookup, the hash, ending sign-ins, `updatePassword()`), the link works
|
|
67
|
+
* again until it expires, and the error is rethrown.
|
|
66
68
|
*/
|
|
67
69
|
reset(token: string, password: string, { signIn }?: ResetPasswordOptions): Promise<PasswordResetResult | null>;
|
|
68
70
|
onModuleDestroy(): Promise<void>;
|
|
69
71
|
private issue;
|
|
72
|
+
/**
|
|
73
|
+
* Checks the link's account and stores the new password's hash. `null` for an account that is gone
|
|
74
|
+
* or changed its address or password since the link was sent.
|
|
75
|
+
*/
|
|
76
|
+
private setPassword;
|
|
70
77
|
/** Ends every session and refresh-token family (with refresh tokens on) of the user. */
|
|
71
78
|
private revokeSignIns;
|
|
72
79
|
/** The options and the handler; the module refuses to start with one and not the other. */
|
|
@@ -93,7 +93,9 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
|
93
93
|
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
94
94
|
* `ForbiddenException` first, as `SignInService.signIn()` does, so the link
|
|
95
95
|
* and the password stay as they were. Without `signIn`, the origin is not
|
|
96
|
-
* checked.
|
|
96
|
+
* checked. When a step throws before the new password is stored (the
|
|
97
|
+
* lookup, the hash, ending sign-ins, `updatePassword()`), the link works
|
|
98
|
+
* again until it expires, and the error is rethrown.
|
|
97
99
|
*/
|
|
98
100
|
async reset(token, password, { signIn = false } = {}) {
|
|
99
101
|
const { handler } = this.feature();
|
|
@@ -109,20 +111,21 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
|
109
111
|
if (!record || hasExpired(record.expiresAt, this.now())) {
|
|
110
112
|
return null;
|
|
111
113
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
let account;
|
|
115
|
+
try {
|
|
116
|
+
account = await this.setPassword(handler, record, password);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// A step failed before the new password was stored, as far as this request can tell: give the
|
|
120
|
+
// link back, so the customer can try it again rather than ask for another email. Safe even if
|
|
121
|
+
// updatePassword() stored the hash and then threw: the link is bound to the fingerprint of the
|
|
122
|
+
// password it replaces, so once that changed, it sets nothing.
|
|
123
|
+
await this.storage.emailTokens.saveEmailToken(record).catch((saveError) => PasswordResetService_1.logger.error('Could not give back a password reset link', saveError instanceof Error ? saveError.stack : saveError));
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
if (!account) {
|
|
118
127
|
return null;
|
|
119
128
|
}
|
|
120
|
-
const passwordHash = await this.hasher.hash(password);
|
|
121
|
-
// Sign-ins end before the new password is stored, so a store that fails leaves the old password
|
|
122
|
-
// without its sessions rather than the new one next to them; and again after, for any sign-in
|
|
123
|
-
// that started in between.
|
|
124
|
-
await this.revokeSignIns(account.id);
|
|
125
|
-
await handler.updatePassword(account.id, passwordHash);
|
|
126
129
|
await this.storage.emailTokens.deleteUserEmailTokens(account.id, 'password-reset');
|
|
127
130
|
await this.revokeSignIns(account.id);
|
|
128
131
|
// The link reached the address, and the password is the owner's now.
|
|
@@ -161,6 +164,27 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
|
161
164
|
url.searchParams.set('token', token);
|
|
162
165
|
await handler.send({ userId: account.id, email: account.email, url: url.toString(), expiresAt });
|
|
163
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Checks the link's account and stores the new password's hash. `null` for an account that is gone
|
|
169
|
+
* or changed its address or password since the link was sent.
|
|
170
|
+
*/
|
|
171
|
+
async setPassword(handler, record, password) {
|
|
172
|
+
const found = await handler.findUser(normalizeEmail(record.email));
|
|
173
|
+
const account = found && checked(found);
|
|
174
|
+
if (!account ||
|
|
175
|
+
account.id !== record.userId ||
|
|
176
|
+
account.email !== record.email ||
|
|
177
|
+
!safeEqual(fingerprint(account.passwordHash), record.fingerprint ?? '')) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const passwordHash = await this.hasher.hash(password);
|
|
181
|
+
// Sign-ins end before the new password is stored, so a store that fails leaves the old password
|
|
182
|
+
// without its sessions rather than the new one next to them; and again after, for any sign-in
|
|
183
|
+
// that started in between.
|
|
184
|
+
await this.revokeSignIns(account.id);
|
|
185
|
+
await handler.updatePassword(account.id, passwordHash);
|
|
186
|
+
return account;
|
|
187
|
+
}
|
|
164
188
|
/** Ends every session and refresh-token family (with refresh tokens on) of the user. */
|
|
165
189
|
async revokeSignIns(userId) {
|
|
166
190
|
await this.sessions.revokeAll(userId);
|
|
@@ -32,8 +32,11 @@ export declare class AuthenticationScope {
|
|
|
32
32
|
get current(): Scope | undefined;
|
|
33
33
|
run<R>(scope: Scope, fn: () => R): R;
|
|
34
34
|
/**
|
|
35
|
-
* The HTTP request and response of the current call: an HTTP handler,
|
|
36
|
-
*
|
|
35
|
+
* The HTTP request and response of the current call: an HTTP handler, a
|
|
36
|
+
* GraphQL resolver whose context carries `req` (and `res`), or an
|
|
37
|
+
* `@nestjs/mcp` call that arrived over HTTP (its context, the second of
|
|
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.
|
|
37
40
|
*/
|
|
38
41
|
exchange(): HttpExchange | undefined;
|
|
39
42
|
}
|
|
@@ -39,8 +39,11 @@ let AuthenticationScope = class AuthenticationScope {
|
|
|
39
39
|
return this.storage.run(scope, fn);
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* The HTTP request and response of the current call: an HTTP handler,
|
|
43
|
-
*
|
|
42
|
+
* The HTTP request and response of the current call: an HTTP handler, a
|
|
43
|
+
* GraphQL resolver whose context carries `req` (and `res`), or an
|
|
44
|
+
* `@nestjs/mcp` call that arrived over HTTP (its context, the second of
|
|
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.
|
|
44
47
|
*/
|
|
45
48
|
exchange() {
|
|
46
49
|
const context = this.current?.context;
|
|
@@ -53,6 +56,10 @@ let AuthenticationScope = class AuthenticationScope {
|
|
|
53
56
|
const gql = context.getArgByIndex(2);
|
|
54
57
|
return gql?.req ? { request: gql.req, response: gql.res } : undefined;
|
|
55
58
|
}
|
|
59
|
+
case 'mcp': {
|
|
60
|
+
const request = context.getArgByIndex(1)?.request;
|
|
61
|
+
return request?.headers ? { request } : undefined;
|
|
62
|
+
}
|
|
56
63
|
default:
|
|
57
64
|
return undefined;
|
|
58
65
|
}
|
|
@@ -37,7 +37,9 @@ export interface EmailTokenStore {
|
|
|
37
37
|
/**
|
|
38
38
|
* Saves a new token (a fresh random id: a plain insert), then keeps the
|
|
39
39
|
* store bounded: deletes tokens whose `expiresAt` is at or before this
|
|
40
|
-
* one's `createdAt`, and past a cap, those that expire first.
|
|
40
|
+
* one's `createdAt`, and past a cap, those that expire first. The services
|
|
41
|
+
* also save back a record `consumeEmailToken()` just returned, when a step
|
|
42
|
+
* after it failed: its id is free again, so that is a plain insert too.
|
|
41
43
|
*/
|
|
42
44
|
saveEmailToken(record: EmailTokenRecord): Promise<void>;
|
|
43
45
|
/**
|
|
@@ -55,7 +55,27 @@ export interface OidcOptions extends OidcClientOptions {
|
|
|
55
55
|
* domain where it started.
|
|
56
56
|
*/
|
|
57
57
|
callbackUrl: string | ((request: OidcRequest) => string | Promise<string>);
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The providers users can sign in with, by name: the `:provider` of your
|
|
60
|
+
* routes, and the `provider` of each profile they sign in with.
|
|
61
|
+
*
|
|
62
|
+
* Providers that aren't known at startup (one OIDC client per customer,
|
|
63
|
+
* created at runtime) come from a function of the name instead, called on
|
|
64
|
+
* the login and again on the callback: return the provider's config, or
|
|
65
|
+
* `undefined` (or `null`) for a name you don't serve, which answers 404.
|
|
66
|
+
* A rotated secret is used from the next call on. Each config it returns
|
|
67
|
+
* gets the checks the object form gets at startup. Caching your lookup is
|
|
68
|
+
* yours to do; discovery documents and key sets are cached by issuer and
|
|
69
|
+
* by URI, whatever the name.
|
|
70
|
+
*
|
|
71
|
+
* Identities are linked by (provider, subject), so a name must stand for
|
|
72
|
+
* one client of one provider: put the customer in the name (`acme`),
|
|
73
|
+
* rather than reusing one name for every customer's own provider. A
|
|
74
|
+
* provider a customer brings vouches for whatever addresses it likes, as
|
|
75
|
+
* verified ones too: link by email only to that customer's accounts, and
|
|
76
|
+
* check an issuer they enter against the providers you support.
|
|
77
|
+
*/
|
|
78
|
+
providers: Record<string, OidcProviderConfig> | ((name: string) => OidcProviderConfig | null | undefined | Promise<OidcProviderConfig | null | undefined>);
|
|
59
79
|
/** Where to land after sign-in when the login had no `redirectTo`. Default `/`. */
|
|
60
80
|
redirectAfterLogin?: string;
|
|
61
81
|
/** Lifetime of a login between the redirect to the provider and the callback. Default `'10m'`. */
|
|
@@ -64,7 +64,10 @@ export declare class MagicLinkService {
|
|
|
64
64
|
* instead, before anything is looked up or burned: the module answers it
|
|
65
65
|
* with a 401 that tells the customer where to open the link. Each
|
|
66
66
|
* refusal is a `magic-link-refused` event with its reason. Outside an
|
|
67
|
-
* HTTP handler, pass the `request` that carries the cookie.
|
|
67
|
+
* HTTP handler, pass the `request` that carries the cookie. A request from
|
|
68
|
+
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
69
|
+
* `ForbiddenException` first, as `SignInService.signIn()` does, so the
|
|
70
|
+
* link and the cookie stay as they were.
|
|
68
71
|
*/
|
|
69
72
|
consume(token: string, { request }?: {
|
|
70
73
|
request?: MagicLinkRequest;
|
|
@@ -146,10 +146,16 @@ let MagicLinkService = class MagicLinkService {
|
|
|
146
146
|
* instead, before anything is looked up or burned: the module answers it
|
|
147
147
|
* with a 401 that tells the customer where to open the link. Each
|
|
148
148
|
* refusal is a `magic-link-refused` event with its reason. Outside an
|
|
149
|
-
* HTTP handler, pass the `request` that carries the cookie.
|
|
149
|
+
* HTTP handler, pass the `request` that carries the cookie. A request from
|
|
150
|
+
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
151
|
+
* `ForbiddenException` first, as `SignInService.signIn()` does, so the
|
|
152
|
+
* link and the cookie stay as they were.
|
|
150
153
|
*/
|
|
151
154
|
async consume(token, { request } = {}) {
|
|
152
155
|
const { handler } = this.feature();
|
|
156
|
+
// Signing in would refuse this request at the end, after the link is spent and the cookie
|
|
157
|
+
// settled: refuse it before, whatever the token is.
|
|
158
|
+
this.signIn.refuseCrossOrigin(this.scope.exchange()?.request);
|
|
153
159
|
if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
|
|
154
160
|
return this.refuse('unknown');
|
|
155
161
|
}
|
|
@@ -1,5 +1,32 @@
|
|
|
1
|
+
import { JwksClient } from '../jwt/jwks.client.js';
|
|
1
2
|
import type { OAuthTokens, OidcProfile, OidcProviderConfig } from '../interfaces/oidc-provider.interface.js';
|
|
2
3
|
import type { OidcTransaction, OidcClientOptions } from '../interfaces/oidc.interface.js';
|
|
4
|
+
interface Endpoints {
|
|
5
|
+
issuer?: string;
|
|
6
|
+
authorization_endpoint: string;
|
|
7
|
+
token_endpoint: string;
|
|
8
|
+
userinfo_endpoint?: string;
|
|
9
|
+
jwks_uri?: string;
|
|
10
|
+
authorization_response_iss_parameter_supported?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* What the clients of one `OidcService` share: discovery documents, by
|
|
14
|
+
* issuer, and key sets, by URI. Neither depends on the client id or secret,
|
|
15
|
+
* so a client made anew for each login (`oidc.providers` as a function)
|
|
16
|
+
* still fetches them once per TTL, and the key set's cooldown still holds.
|
|
17
|
+
*/
|
|
18
|
+
export declare class OidcMetadataCache {
|
|
19
|
+
private readonly options;
|
|
20
|
+
private readonly documents;
|
|
21
|
+
private readonly keySets;
|
|
22
|
+
constructor(options?: OidcClientOptions);
|
|
23
|
+
/** The discovery document of `issuer`, as published: the endpoints a config overrides are its own to merge. */
|
|
24
|
+
document(issuer: string): Promise<Endpoints>;
|
|
25
|
+
/** The key set at `uri`, an https URL. */
|
|
26
|
+
jwks(uri: string): JwksClient;
|
|
27
|
+
private discover;
|
|
28
|
+
private now;
|
|
29
|
+
}
|
|
3
30
|
/**
|
|
4
31
|
* One OAuth 2.0 / OpenID Connect provider: authorization code flow with
|
|
5
32
|
* PKCE (S256), `state` and `nonce`.
|
|
@@ -13,10 +40,9 @@ export declare class OidcClient {
|
|
|
13
40
|
readonly name: string;
|
|
14
41
|
private readonly config;
|
|
15
42
|
private readonly options;
|
|
16
|
-
private
|
|
17
|
-
private jwks?;
|
|
43
|
+
private readonly metadata;
|
|
18
44
|
readonly kind: 'oidc' | 'oauth2';
|
|
19
|
-
constructor(name: string, config: OidcProviderConfig, options?: OidcClientOptions);
|
|
45
|
+
constructor(name: string, config: OidcProviderConfig, options?: OidcClientOptions, metadata?: OidcMetadataCache);
|
|
20
46
|
/**
|
|
21
47
|
* The provider's authorization URL, the transaction to store, and the
|
|
22
48
|
* browser binding for the transaction cookie. `state` is the SHA-256 of
|
|
@@ -37,8 +63,8 @@ export declare class OidcClient {
|
|
|
37
63
|
private verifyIdToken;
|
|
38
64
|
private exchange;
|
|
39
65
|
private endpoints;
|
|
40
|
-
private discover;
|
|
41
66
|
private getJson;
|
|
42
67
|
private request;
|
|
43
68
|
private now;
|
|
44
69
|
}
|
|
70
|
+
export {};
|
package/dist/oidc/oidc.client.js
CHANGED
|
@@ -8,8 +8,10 @@ import { OidcError } from '../errors/oidc.error.js';
|
|
|
8
8
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
9
9
|
/**
|
|
10
10
|
* Only https, except loopback http (local IdPs and tests). The URLs come from the provider's
|
|
11
|
-
* discovery document or the app's configuration, never from the
|
|
12
|
-
* misconfiguration (`unavailable`, a 502), not a failed sign-in.
|
|
11
|
+
* discovery document or the app's configuration, never from the request: a bad one is a
|
|
12
|
+
* misconfiguration (`unavailable`, a 502), not a failed sign-in. Nor does this keep a fetch off
|
|
13
|
+
* the app's own network: an issuer that customers enter themselves is the app's to check
|
|
14
|
+
* against the providers it supports, before it stores it.
|
|
13
15
|
*/
|
|
14
16
|
function assertSecureUrl(value, what) {
|
|
15
17
|
let url;
|
|
@@ -24,6 +26,66 @@ function assertSecureUrl(value, what) {
|
|
|
24
26
|
}
|
|
25
27
|
return url.toString();
|
|
26
28
|
}
|
|
29
|
+
/** A request to the provider: no redirects, a 10 s timeout, and a network failure as an outage. */
|
|
30
|
+
async function send(options, url, init) {
|
|
31
|
+
try {
|
|
32
|
+
return await (options.fetch ?? fetch)(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new OidcError(`${new URL(url).host} is unreachable (${error.message})`, 'unavailable');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What the clients of one `OidcService` share: discovery documents, by
|
|
40
|
+
* issuer, and key sets, by URI. Neither depends on the client id or secret,
|
|
41
|
+
* so a client made anew for each login (`oidc.providers` as a function)
|
|
42
|
+
* still fetches them once per TTL, and the key set's cooldown still holds.
|
|
43
|
+
*/
|
|
44
|
+
export class OidcMetadataCache {
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.options = options;
|
|
47
|
+
this.documents = new Map();
|
|
48
|
+
this.keySets = new Map();
|
|
49
|
+
}
|
|
50
|
+
/** The discovery document of `issuer`, as published: the endpoints a config overrides are its own to merge. */
|
|
51
|
+
document(issuer) {
|
|
52
|
+
const ttl = durationOr(this.options.discoveryTtl, '1h');
|
|
53
|
+
let cached = this.documents.get(issuer);
|
|
54
|
+
if (!cached || this.now() - cached.at >= ttl) {
|
|
55
|
+
const entry = { value: this.discover(issuer), at: this.now() };
|
|
56
|
+
this.documents.set(issuer, entry);
|
|
57
|
+
// Retry next time, unless a later fetch has taken its place.
|
|
58
|
+
entry.value.catch(() => this.documents.get(issuer) === entry && this.documents.delete(issuer));
|
|
59
|
+
cached = entry;
|
|
60
|
+
}
|
|
61
|
+
return cached.value;
|
|
62
|
+
}
|
|
63
|
+
/** The key set at `uri`, an https URL. */
|
|
64
|
+
jwks(uri) {
|
|
65
|
+
let jwks = this.keySets.get(uri);
|
|
66
|
+
if (!jwks) {
|
|
67
|
+
jwks = new JwksClient(uri, { ...this.options.jwks, fetch: this.options.fetch, now: this.options.now });
|
|
68
|
+
this.keySets.set(uri, jwks);
|
|
69
|
+
}
|
|
70
|
+
return jwks;
|
|
71
|
+
}
|
|
72
|
+
async discover(issuer) {
|
|
73
|
+
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
74
|
+
const response = await send(this.options, assertSecureUrl(url, 'issuer'), { headers: { accept: 'application/json' } });
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new OidcError(`discovery failed (${response.status})`, 'unavailable');
|
|
77
|
+
}
|
|
78
|
+
const doc = (await response.json().catch(() => ({})));
|
|
79
|
+
// OIDC Discovery §4.3: the document must name exactly the issuer we asked.
|
|
80
|
+
if (doc.issuer !== issuer) {
|
|
81
|
+
throw new OidcError('discovery issuer mismatch', 'unavailable');
|
|
82
|
+
}
|
|
83
|
+
return doc;
|
|
84
|
+
}
|
|
85
|
+
now() {
|
|
86
|
+
return this.options.now?.() ?? Date.now();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
27
89
|
/**
|
|
28
90
|
* One OAuth 2.0 / OpenID Connect provider: authorization code flow with
|
|
29
91
|
* PKCE (S256), `state` and `nonce`.
|
|
@@ -34,10 +96,11 @@ function assertSecureUrl(value, what) {
|
|
|
34
96
|
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
35
97
|
*/
|
|
36
98
|
export class OidcClient {
|
|
37
|
-
constructor(name, config, options = {}) {
|
|
99
|
+
constructor(name, config, options = {}, metadata = new OidcMetadataCache(options)) {
|
|
38
100
|
this.name = name;
|
|
39
101
|
this.config = config;
|
|
40
102
|
this.options = options;
|
|
103
|
+
this.metadata = metadata;
|
|
41
104
|
this.kind = config.kind ?? 'oidc';
|
|
42
105
|
if (this.kind === 'oidc' && !config.issuer) {
|
|
43
106
|
throw new Error(`OIDC provider '${name}' needs an issuer`);
|
|
@@ -138,13 +201,8 @@ export class OidcClient {
|
|
|
138
201
|
};
|
|
139
202
|
}
|
|
140
203
|
async verifyIdToken(endpoints, idToken, nonce) {
|
|
141
|
-
this.jwks ??= new JwksClient(assertSecureUrl(endpoints.jwks_uri, 'jwks_uri'), {
|
|
142
|
-
...this.options.jwks,
|
|
143
|
-
fetch: this.options.fetch,
|
|
144
|
-
now: this.options.now,
|
|
145
|
-
});
|
|
146
204
|
const verifier = new JwtVerifier({
|
|
147
|
-
jwks: this.jwks,
|
|
205
|
+
jwks: this.metadata.jwks(assertSecureUrl(endpoints.jwks_uri, 'jwks_uri')),
|
|
148
206
|
algorithms: this.config.idTokenAlgorithms ?? ['RS256', 'ES256'],
|
|
149
207
|
issuer: endpoints.issuer,
|
|
150
208
|
audience: this.config.clientId,
|
|
@@ -229,26 +287,7 @@ export class OidcClient {
|
|
|
229
287
|
}),
|
|
230
288
|
};
|
|
231
289
|
}
|
|
232
|
-
const
|
|
233
|
-
if (!this.discovery || this.now() - this.discovery.at >= ttl) {
|
|
234
|
-
const value = this.discover();
|
|
235
|
-
this.discovery = { value, at: this.now() };
|
|
236
|
-
value.catch(() => (this.discovery = undefined)); // retry next time
|
|
237
|
-
}
|
|
238
|
-
return this.discovery.value;
|
|
239
|
-
}
|
|
240
|
-
async discover() {
|
|
241
|
-
const issuer = this.config.issuer;
|
|
242
|
-
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
243
|
-
const response = await this.request(assertSecureUrl(url, 'issuer'), { headers: { accept: 'application/json' } });
|
|
244
|
-
if (!response.ok) {
|
|
245
|
-
throw new OidcError(`discovery failed (${response.status})`, 'unavailable');
|
|
246
|
-
}
|
|
247
|
-
const doc = (await response.json().catch(() => ({})));
|
|
248
|
-
// OIDC Discovery §4.3: the document must name exactly the issuer we asked.
|
|
249
|
-
if (doc.issuer !== issuer) {
|
|
250
|
-
throw new OidcError('discovery issuer mismatch', 'unavailable');
|
|
251
|
-
}
|
|
290
|
+
const doc = await this.metadata.document(this.config.issuer);
|
|
252
291
|
return {
|
|
253
292
|
...doc,
|
|
254
293
|
authorization_endpoint: assertSecureUrl(this.config.authorizationEndpoint ?? doc.authorization_endpoint, 'authorization_endpoint'),
|
|
@@ -272,13 +311,8 @@ export class OidcClient {
|
|
|
272
311
|
}
|
|
273
312
|
return response.json();
|
|
274
313
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return await (this.options.fetch ?? fetch)(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
278
|
-
}
|
|
279
|
-
catch (error) {
|
|
280
|
-
throw new OidcError(`${new URL(url).host} is unreachable (${error.message})`, 'unavailable');
|
|
281
|
-
}
|
|
314
|
+
request(url, init) {
|
|
315
|
+
return send(this.options, url, init);
|
|
282
316
|
}
|
|
283
317
|
now() {
|
|
284
318
|
return this.options.now?.() ?? Date.now();
|
|
@@ -5,7 +5,6 @@ import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
|
5
5
|
import { SessionService } from '../session/session.service.js';
|
|
6
6
|
import { SignInService } from '../session/sign-in.service.js';
|
|
7
7
|
import type { OidcOptions, OidcRequest, OidcRedirect, OidcStartOptions } from '../interfaces/oidc.interface.js';
|
|
8
|
-
import { OidcClient } from './oidc.client.js';
|
|
9
8
|
/**
|
|
10
9
|
* OpenID Connect sign-in, behind two routes of your own: a login route that
|
|
11
10
|
* redirects to the provider, and the callback route it sends the browser
|
|
@@ -53,14 +52,13 @@ export declare class OidcService {
|
|
|
53
52
|
private readonly sessions;
|
|
54
53
|
private readonly scope;
|
|
55
54
|
private readonly adapterHost?;
|
|
56
|
-
private readonly
|
|
55
|
+
private readonly metadata;
|
|
57
56
|
private readonly options?;
|
|
58
57
|
private readonly transactionTtl;
|
|
59
58
|
private readonly txCookieName;
|
|
60
59
|
constructor(storage: AuthenticationStorage, registry: AuthenticationRegistry, signIn: SignInService, sessions: SessionService, options?: {
|
|
61
60
|
oidc?: OidcOptions;
|
|
62
61
|
}, scope?: AuthenticationScope, adapterHost?: HttpAdapterHost | undefined);
|
|
63
|
-
client(name: string): OidcClient;
|
|
64
62
|
/**
|
|
65
63
|
* Starts a login: stores the transaction, sets its cookie, and returns
|
|
66
64
|
* the provider's authorization URL. 404 for a provider that isn't
|
|
@@ -82,6 +80,13 @@ export declare class OidcService {
|
|
|
82
80
|
private feature;
|
|
83
81
|
/** A fully signed-in session (second factor done, if any) on this request. */
|
|
84
82
|
private currentSession;
|
|
83
|
+
/**
|
|
84
|
+
* The client of the provider named `name`, made for this call from its
|
|
85
|
+
* config: a secret the `oidc.providers` function rotates is the one the
|
|
86
|
+
* next call uses. 404 for a provider that isn't configured, or that the
|
|
87
|
+
* function doesn't serve.
|
|
88
|
+
*/
|
|
89
|
+
private client;
|
|
85
90
|
/** `oidc.callbackUrl` for this request, with the provider's name in it. */
|
|
86
91
|
private callbackUrl;
|
|
87
92
|
private txCookie;
|
|
@@ -23,7 +23,7 @@ import { defaultCookieName, readCookie, serializeCookie } from '../session/cooki
|
|
|
23
23
|
import { SessionService } from '../session/session.service.js';
|
|
24
24
|
import { SignInService } from '../session/sign-in.service.js';
|
|
25
25
|
import { OidcError } from '../errors/oidc.error.js';
|
|
26
|
-
import { OidcClient } from './oidc.client.js';
|
|
26
|
+
import { OidcClient, OidcMetadataCache } from './oidc.client.js';
|
|
27
27
|
const header = (headers, name) => {
|
|
28
28
|
const value = headers[name];
|
|
29
29
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -76,30 +76,19 @@ let OidcService = class OidcService {
|
|
|
76
76
|
this.sessions = sessions;
|
|
77
77
|
this.scope = scope;
|
|
78
78
|
this.adapterHost = adapterHost;
|
|
79
|
-
this.clients = new Map();
|
|
80
79
|
this.options = options?.oidc;
|
|
81
80
|
if (this.options) {
|
|
82
81
|
if (typeof this.options.callbackUrl !== 'function') {
|
|
83
82
|
assertCallbackUrl(this.options.callbackUrl);
|
|
84
83
|
}
|
|
85
|
-
|
|
84
|
+
if (typeof this.options.providers !== 'function') {
|
|
85
|
+
assertProviderCredentials(this.options.providers);
|
|
86
|
+
}
|
|
86
87
|
}
|
|
88
|
+
this.metadata = new OidcMetadataCache(this.options);
|
|
87
89
|
this.transactionTtl = durationOr(this.options?.transactionTtl, '10m');
|
|
88
90
|
this.txCookieName = defaultCookieName('oidc_tx', this.options?.cookie);
|
|
89
91
|
}
|
|
90
|
-
client(name) {
|
|
91
|
-
const { options } = this.feature();
|
|
92
|
-
const config = Object.hasOwn(options.providers, name) ? options.providers[name] : undefined;
|
|
93
|
-
if (!config) {
|
|
94
|
-
throw new NotFoundException();
|
|
95
|
-
}
|
|
96
|
-
let client = this.clients.get(name);
|
|
97
|
-
if (!client) {
|
|
98
|
-
client = new OidcClient(name, config, options);
|
|
99
|
-
this.clients.set(name, client);
|
|
100
|
-
}
|
|
101
|
-
return client;
|
|
102
|
-
}
|
|
103
92
|
/**
|
|
104
93
|
* Starts a login: stores the transaction, sets its cookie, and returns
|
|
105
94
|
* the provider's authorization URL. 404 for a provider that isn't
|
|
@@ -121,7 +110,8 @@ let OidcService = class OidcService {
|
|
|
121
110
|
}
|
|
122
111
|
linking = { userId: session.userId, sessionId: session.id };
|
|
123
112
|
}
|
|
124
|
-
const
|
|
113
|
+
const client = await this.client(provider);
|
|
114
|
+
const { url, transaction, binding } = await client
|
|
125
115
|
.authorizationRequest(await this.callbackUrl(provider, req), safeRedirectPath(redirectTo), this.transactionTtl)
|
|
126
116
|
.catch(toHttp);
|
|
127
117
|
await this.storage.oidcStates.saveOidcState({ ...transaction, ...(linking && { link: linking }) });
|
|
@@ -151,7 +141,8 @@ let OidcService = class OidcService {
|
|
|
151
141
|
// Asked again rather than stored with the login: the cookie keeps the callback on the login's host,
|
|
152
142
|
// and a function that answers otherwise gets the code refused by the provider, not accepted.
|
|
153
143
|
const callbackUrl = await this.callbackUrl(provider, req);
|
|
154
|
-
const
|
|
144
|
+
const client = await this.client(provider);
|
|
145
|
+
const result = await client.callback(query, transaction, callbackUrl).catch(toHttp);
|
|
155
146
|
const location = transaction.redirectTo ?? options.redirectAfterLogin ?? '/';
|
|
156
147
|
if (transaction.link) {
|
|
157
148
|
// The session that asked to link must still be the one on this browser.
|
|
@@ -199,6 +190,29 @@ let OidcService = class OidcService {
|
|
|
199
190
|
const session = await this.sessions.validate(this.sessions.tokenFrom(request));
|
|
200
191
|
return session && session.mfa !== 'pending' ? session : null;
|
|
201
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* The client of the provider named `name`, made for this call from its
|
|
195
|
+
* config: a secret the `oidc.providers` function rotates is the one the
|
|
196
|
+
* next call uses. 404 for a provider that isn't configured, or that the
|
|
197
|
+
* function doesn't serve.
|
|
198
|
+
*/
|
|
199
|
+
async client(name) {
|
|
200
|
+
const { providers } = this.feature().options;
|
|
201
|
+
let config;
|
|
202
|
+
if (typeof providers === 'function') {
|
|
203
|
+
config = await providers(name);
|
|
204
|
+
if (config) {
|
|
205
|
+
assertResolvedCredentials(name, config);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
config = Object.hasOwn(providers, name) ? providers[name] : undefined;
|
|
210
|
+
}
|
|
211
|
+
if (!config) {
|
|
212
|
+
throw new NotFoundException();
|
|
213
|
+
}
|
|
214
|
+
return new OidcClient(name, config, this.options, this.metadata);
|
|
215
|
+
}
|
|
202
216
|
/** `oidc.callbackUrl` for this request, with the provider's name in it. */
|
|
203
217
|
async callbackUrl(provider, request) {
|
|
204
218
|
const { callbackUrl } = this.feature().options;
|
|
@@ -244,6 +258,10 @@ function assertCallbackUrl(value, must = '`oidc.callbackUrl` must be') {
|
|
|
244
258
|
`provider's name goes, e.g. 'https://app.example.com/auth/oidc/:provider/callback'. Got ${JSON.stringify(value)}.`);
|
|
245
259
|
}
|
|
246
260
|
}
|
|
261
|
+
const CREDENTIALS = {
|
|
262
|
+
clientId: 'the client id the provider issued',
|
|
263
|
+
clientSecret: "the client secret the provider issued; a public client sets `tokenEndpointAuthMethod: 'none'` instead",
|
|
264
|
+
};
|
|
247
265
|
/**
|
|
248
266
|
* `oidc.providers.<name>.clientId`, and `clientSecret` unless the provider
|
|
249
267
|
* is a public client (`tokenEndpointAuthMethod: 'none'`), checked at
|
|
@@ -251,11 +269,20 @@ function assertCallbackUrl(value, must = '`oidc.callbackUrl` must be') {
|
|
|
251
269
|
* confidential client into a public one, and fail at the token exchange.
|
|
252
270
|
*/
|
|
253
271
|
function assertProviderCredentials(providers) {
|
|
254
|
-
requireOption(providers, 'oidc.providers', 'the providers users can sign in with, by name');
|
|
272
|
+
requireOption(providers, 'oidc.providers', 'the providers users can sign in with, by name, or a function of the name');
|
|
255
273
|
for (const [name, config] of Object.entries(providers)) {
|
|
256
|
-
requireOption(config?.clientId, `oidc.providers.${name}.clientId`,
|
|
274
|
+
requireOption(config?.clientId, `oidc.providers.${name}.clientId`, CREDENTIALS.clientId);
|
|
257
275
|
if (config.tokenEndpointAuthMethod !== 'none') {
|
|
258
|
-
requireOption(config.clientSecret, `oidc.providers.${name}.clientSecret`,
|
|
276
|
+
requireOption(config.clientSecret, `oidc.providers.${name}.clientSecret`, CREDENTIALS.clientSecret);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** The same checks, on each config the `oidc.providers` function returns. */
|
|
281
|
+
function assertResolvedCredentials(name, config) {
|
|
282
|
+
for (const key of ['clientId', 'clientSecret']) {
|
|
283
|
+
if (!config[key] && (key === 'clientId' || config.tokenEndpointAuthMethod !== 'none')) {
|
|
284
|
+
throw new Error(`OidcService: the \`oidc.providers\` function returned the provider '${name}' without its \`${key}\` ` +
|
|
285
|
+
`(${CREDENTIALS[key]}).`);
|
|
259
286
|
}
|
|
260
287
|
}
|
|
261
288
|
}
|
|
@@ -14,7 +14,9 @@ export declare function isHashablePassword(password: unknown): password is strin
|
|
|
14
14
|
* The encoded hash is self-describing (`$scrypt$ln=17,r=8,p=1$salt$hash`),
|
|
15
15
|
* so parameters can be raised later: `verify()` reads the parameters from
|
|
16
16
|
* the stored hash and `needsRehash()` tells the sign-in code to store a
|
|
17
|
-
* fresh hash while it has the plaintext.
|
|
17
|
+
* fresh hash while it has the plaintext. Until then, `verify()` makes up the
|
|
18
|
+
* work an older, cheaper hash saves, so it answers no sooner than for an
|
|
19
|
+
* unknown user.
|
|
18
20
|
*
|
|
19
21
|
* Passwords are NFKC-normalised (NIST SP 800-63B §5.1.1.2) so the same
|
|
20
22
|
* password typed on different keyboards verifies. `hash()` refuses an empty
|
|
@@ -37,6 +39,11 @@ export declare class PasswordHasher {
|
|
|
37
39
|
* is checked instead, so response time does not reveal which accounts
|
|
38
40
|
* exist. The first such call makes the dummy hash, which costs the same
|
|
39
41
|
* one derivation as every later check.
|
|
42
|
+
*
|
|
43
|
+
* A hash stored before the cost was raised is cheaper to check than the
|
|
44
|
+
* dummy, so its check is topped up to the configured work, right password
|
|
45
|
+
* or wrong. One stored before the cost was lowered takes its longer time
|
|
46
|
+
* until it is rehashed: that can't be hidden.
|
|
40
47
|
*/
|
|
41
48
|
verify(password: string, encoded: string | null | undefined): Promise<boolean>;
|
|
42
49
|
/** `true` when the stored hash uses weaker or different parameters than configured. */
|
|
@@ -8,6 +8,8 @@ function scrypt(password, salt, keyLength, { logN, r, p }) {
|
|
|
8
8
|
return new Promise((resolve, reject) => scryptCb(password, salt, keyLength, options, (err, key) => (err ? reject(err) : resolve(key))));
|
|
9
9
|
}
|
|
10
10
|
const b64 = (buf) => buf.toString('base64').replace(/=+$/, '');
|
|
11
|
+
/** What a derivation costs: N·r·p, since OpenSSL runs the `p` lanes one after another. */
|
|
12
|
+
const work = ({ logN, r, p }) => 2 ** logN * r * p;
|
|
11
13
|
/**
|
|
12
14
|
* @internal The bytes `PasswordHasher` derives from: the password NFKC-normalised, as UTF-8. Throws
|
|
13
15
|
* for anything but a string, for an empty one (which no account should have), and past 4 KiB,
|
|
@@ -48,7 +50,9 @@ export function isHashablePassword(password) {
|
|
|
48
50
|
* The encoded hash is self-describing (`$scrypt$ln=17,r=8,p=1$salt$hash`),
|
|
49
51
|
* so parameters can be raised later: `verify()` reads the parameters from
|
|
50
52
|
* the stored hash and `needsRehash()` tells the sign-in code to store a
|
|
51
|
-
* fresh hash while it has the plaintext.
|
|
53
|
+
* fresh hash while it has the plaintext. Until then, `verify()` makes up the
|
|
54
|
+
* work an older, cheaper hash saves, so it answers no sooner than for an
|
|
55
|
+
* unknown user.
|
|
52
56
|
*
|
|
53
57
|
* Passwords are NFKC-normalised (NIST SP 800-63B §5.1.1.2) so the same
|
|
54
58
|
* password typed on different keyboards verifies. `hash()` refuses an empty
|
|
@@ -86,6 +90,11 @@ export class PasswordHasher {
|
|
|
86
90
|
* is checked instead, so response time does not reveal which accounts
|
|
87
91
|
* exist. The first such call makes the dummy hash, which costs the same
|
|
88
92
|
* one derivation as every later check.
|
|
93
|
+
*
|
|
94
|
+
* A hash stored before the cost was raised is cheaper to check than the
|
|
95
|
+
* dummy, so its check is topped up to the configured work, right password
|
|
96
|
+
* or wrong. One stored before the cost was lowered takes its longer time
|
|
97
|
+
* until it is rehashed: that can't be hidden.
|
|
89
98
|
*/
|
|
90
99
|
async verify(password, encoded) {
|
|
91
100
|
if (!encoded) {
|
|
@@ -116,6 +125,17 @@ export class PasswordHasher {
|
|
|
116
125
|
return false;
|
|
117
126
|
}
|
|
118
127
|
const actual = await this.derive(input, parsed.salt, parsed.hash.length, parsed.params);
|
|
128
|
+
// A hash stored before the cost was raised takes less work than the dummy, which would tell an
|
|
129
|
+
// account from an unknown email: make up the difference in N·r·p with derivations at the
|
|
130
|
+
// configured r and p, halving N each time. Exact when only N changed, within 2·r·p otherwise.
|
|
131
|
+
let missing = work(this.params) - work(parsed.params);
|
|
132
|
+
for (let logN = this.params.logN - 1; logN >= 1 && missing > 0; logN--) {
|
|
133
|
+
const params = { ...this.params, logN };
|
|
134
|
+
if (work(params) <= missing) {
|
|
135
|
+
await this.derive(input, parsed.salt, this.keyLength, params);
|
|
136
|
+
missing -= work(params);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
119
139
|
return timingSafeEqual(actual, parsed.hash);
|
|
120
140
|
}
|
|
121
141
|
/** `true` when the stored hash uses weaker or different parameters than configured. */
|
package/dist/testing/index.js
CHANGED
|
@@ -113,16 +113,15 @@ export function authenticationStoreContract(create, options = {}) {
|
|
|
113
113
|
// Two instances: one clock a little behind the other.
|
|
114
114
|
const behind = new SessionService(storage, { session: { ...options, now: () => clock - 2_000 } });
|
|
115
115
|
const ahead = new SessionService(storage, { session: { ...options, now: () => clock } });
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}, concurrent);
|
|
116
|
+
const { token } = await ahead.create(uid());
|
|
117
|
+
clock += 61_000; // idle for 61 s on `ahead`, 59 s on `behind`
|
|
118
|
+
// One after the other: run together, `behind` may read and touch first, and then `ahead` rightly
|
|
119
|
+
// finds the session alive. In this order, a `validate()` that deleted what it saw idle fails below.
|
|
120
|
+
same(await ahead.validate(token), null, 'the request that saw the session idle is refused');
|
|
121
|
+
assert.ok(await behind.validate(token), 'the other instance, within the idle timeout, touches the session');
|
|
122
|
+
clock += 1_000;
|
|
123
|
+
assert.ok(await ahead.validate(token), 'the touched session is still there');
|
|
124
|
+
});
|
|
126
125
|
add('sessions', 'a request racing a sign-out or a rotation never brings the session back (SessionService)', async (sources) => {
|
|
127
126
|
let clock = T0;
|
|
128
127
|
const sessions = new SessionService(storageOf(sources, 'sessions'), {
|
|
@@ -414,6 +413,9 @@ export function authenticationStoreContract(create, options = {}) {
|
|
|
414
413
|
}
|
|
415
414
|
same(await store.consumeEmailToken(reset.id, 'email-verification'), undefined, 'a token of another purpose is refused');
|
|
416
415
|
same(await store.consumeEmailToken(reset.id, 'password-reset'), reset, '…and left in place');
|
|
416
|
+
// What the services do when a step after the consume fails.
|
|
417
|
+
await store.saveEmailToken(reset);
|
|
418
|
+
same(await store.consumeEmailToken(reset.id, 'password-reset'), reset, 'a consumed token can be saved back, and works once more');
|
|
417
419
|
const second = emailToken({ userId: u1 });
|
|
418
420
|
await store.saveEmailToken(second);
|
|
419
421
|
await store.deleteUserEmailTokens(u1, 'password-reset');
|
|
@@ -38,13 +38,16 @@ export declare function getRawResult(context: ExecutionContext): State | undefin
|
|
|
38
38
|
* (`WsAuthenticator.authenticateConnection()` stores the upgrade request
|
|
39
39
|
* there for the `ws` library)
|
|
40
40
|
* - `graphql`: `context.req.headers`
|
|
41
|
+
* - `mcp`: the headers of the HTTP request the call arrived in; `undefined`
|
|
42
|
+
* over stdio, where the MCP spec takes credentials from the environment
|
|
41
43
|
* - `rpc`: `undefined`; metadata is transport specific
|
|
42
44
|
*/
|
|
43
45
|
export declare function getRequestHeaders(context: ExecutionContext): Headers | undefined;
|
|
44
46
|
/**
|
|
45
47
|
* The request behind a call, as far as authentication needs it: headers,
|
|
46
|
-
* and the method for HTTP and
|
|
47
|
-
* it counts as a safe request). `undefined` for RPC
|
|
48
|
+
* and the method for HTTP, GraphQL and MCP (a WebSocket handshake has none,
|
|
49
|
+
* so it counts as a safe request). `undefined` for RPC, and for MCP over
|
|
50
|
+
* stdio.
|
|
48
51
|
*/
|
|
49
52
|
export declare function requestOf(context: ExecutionContext): {
|
|
50
53
|
headers: Headers;
|
|
@@ -28,12 +28,27 @@ function callKeys(context) {
|
|
|
28
28
|
}
|
|
29
29
|
return keys;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The context of an `@nestjs/mcp` call (a tool, resource or prompt), read from
|
|
33
|
+
* its arguments, `[input, context]`, so this package needs no `@nestjs/mcp`
|
|
34
|
+
* dependency. Its `request` is the HTTP platform's request (a plain
|
|
35
|
+
* `{ method, url, headers }` object for `@nestjs/mcp/testing`'s in-memory
|
|
36
|
+
* client), absent over stdio.
|
|
37
|
+
*/
|
|
38
|
+
function mcpCallOf(context) {
|
|
39
|
+
const call = context.getArgByIndex(1);
|
|
40
|
+
return typeof call === 'object' && call !== null ? call : undefined;
|
|
41
|
+
}
|
|
31
42
|
/**
|
|
32
43
|
* The object that carries the result for one call, mirrored as `user` /
|
|
33
44
|
* `session` where Passport-era code and `@nestjs/authorization` look:
|
|
34
45
|
* the request (http), the client socket (ws), the transport context (rpc),
|
|
35
46
|
* `context.req` (graphql; over graphql-ws, the socket's upgrade request, which
|
|
36
|
-
* every operation of the socket shares)
|
|
47
|
+
* every operation of the socket shares), and for an MCP call (`mcp`) the HTTP
|
|
48
|
+
* request it arrived in, else (over stdio) its own context object. Each MCP
|
|
49
|
+
* request travels in an HTTP request of its own, so the request is per call,
|
|
50
|
+
* and it is the one the endpoint's route was authenticated on: a guard there
|
|
51
|
+
* and on the call authenticate once.
|
|
37
52
|
*/
|
|
38
53
|
function carrierOf(context) {
|
|
39
54
|
switch (context.getType()) {
|
|
@@ -47,6 +62,10 @@ function carrierOf(context) {
|
|
|
47
62
|
}
|
|
48
63
|
case 'graphql':
|
|
49
64
|
return context.getArgByIndex(2)?.req;
|
|
65
|
+
case 'mcp': {
|
|
66
|
+
const call = mcpCallOf(context);
|
|
67
|
+
return call?.request ?? call;
|
|
68
|
+
}
|
|
50
69
|
default:
|
|
51
70
|
return undefined;
|
|
52
71
|
}
|
|
@@ -178,6 +197,8 @@ function stateCarrierOf(context) {
|
|
|
178
197
|
* (`WsAuthenticator.authenticateConnection()` stores the upgrade request
|
|
179
198
|
* there for the `ws` library)
|
|
180
199
|
* - `graphql`: `context.req.headers`
|
|
200
|
+
* - `mcp`: the headers of the HTTP request the call arrived in; `undefined`
|
|
201
|
+
* over stdio, where the MCP spec takes credentials from the environment
|
|
181
202
|
* - `rpc`: `undefined`; metadata is transport specific
|
|
182
203
|
*/
|
|
183
204
|
export function getRequestHeaders(context) {
|
|
@@ -185,8 +206,9 @@ export function getRequestHeaders(context) {
|
|
|
185
206
|
}
|
|
186
207
|
/**
|
|
187
208
|
* The request behind a call, as far as authentication needs it: headers,
|
|
188
|
-
* and the method for HTTP and
|
|
189
|
-
* it counts as a safe request). `undefined` for RPC
|
|
209
|
+
* and the method for HTTP, GraphQL and MCP (a WebSocket handshake has none,
|
|
210
|
+
* so it counts as a safe request). `undefined` for RPC, and for MCP over
|
|
211
|
+
* stdio.
|
|
190
212
|
*/
|
|
191
213
|
export function requestOf(context) {
|
|
192
214
|
switch (context.getType()) {
|
|
@@ -199,6 +221,10 @@ export function requestOf(context) {
|
|
|
199
221
|
}
|
|
200
222
|
case 'graphql':
|
|
201
223
|
return context.getArgByIndex(2)?.req;
|
|
224
|
+
case 'mcp': {
|
|
225
|
+
const request = mcpCallOf(context)?.request;
|
|
226
|
+
return request?.headers ? request : undefined;
|
|
227
|
+
}
|
|
202
228
|
default:
|
|
203
229
|
return undefined;
|
|
204
230
|
}
|
|
@@ -23,13 +23,15 @@ export declare function refusalOf(error: AuthenticationError): Refusal;
|
|
|
23
23
|
* for `AuthenticationError`s thrown by handlers: the error each transport's
|
|
24
24
|
* exception filter understands, for the refusal's status.
|
|
25
25
|
*
|
|
26
|
-
* - HTTP and
|
|
26
|
+
* - HTTP, GraphQL and MCP: Nest's own `UnauthorizedException` (or
|
|
27
27
|
* `ForbiddenException` for a 403, `ConflictException` for a 409), with
|
|
28
28
|
* Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
|
|
29
29
|
* message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
|
|
30
30
|
* A `code` is Nest's `errorCode` (on the exception, and in the body), and
|
|
31
31
|
* the description in `error` when there is a message; `details` follow.
|
|
32
32
|
* Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
|
|
33
|
+
* `@nestjs/mcp` answers a tool call's exception with a tool error carrying
|
|
34
|
+
* its message, and a resource's or prompt's with a JSON-RPC error.
|
|
33
35
|
* - ws and rpc: a `WsException` / `RpcException` carrying
|
|
34
36
|
* `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
|
|
35
37
|
* `errorCode` and `details` as above). Those filters report
|
|
@@ -21,13 +21,15 @@ export function refusalOf(error) {
|
|
|
21
21
|
* for `AuthenticationError`s thrown by handlers: the error each transport's
|
|
22
22
|
* exception filter understands, for the refusal's status.
|
|
23
23
|
*
|
|
24
|
-
* - HTTP and
|
|
24
|
+
* - HTTP, GraphQL and MCP: Nest's own `UnauthorizedException` (or
|
|
25
25
|
* `ForbiddenException` for a 403, `ConflictException` for a 409), with
|
|
26
26
|
* Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
|
|
27
27
|
* message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
|
|
28
28
|
* A `code` is Nest's `errorCode` (on the exception, and in the body), and
|
|
29
29
|
* the description in `error` when there is a message; `details` follow.
|
|
30
30
|
* Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
|
|
31
|
+
* `@nestjs/mcp` answers a tool call's exception with a tool error carrying
|
|
32
|
+
* its message, and a resource's or prompt's with a JSON-RPC error.
|
|
31
33
|
* - ws and rpc: a `WsException` / `RpcException` carrying
|
|
32
34
|
* `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
|
|
33
35
|
* `errorCode` and `details` as above). Those filters report
|
|
@@ -66,6 +68,12 @@ export async function refusal(context, { status = 401, message, code, details, c
|
|
|
66
68
|
adapterHost?.httpAdapter?.setHeader(context.switchToHttp().getResponse(), 'WWW-Authenticate', challenge);
|
|
67
69
|
}
|
|
68
70
|
break;
|
|
71
|
+
case 'mcp':
|
|
72
|
+
// Nest's own exception, as for HTTP, which `@nestjs/mcp` answers with: a tool result with `isError: true` and
|
|
73
|
+
// the message, for a tool; a JSON-RPC error with the message and the status, for a resource or a prompt. The
|
|
74
|
+
// MCP SDK writes the HTTP response, so no challenge goes with it: an MCP client learns where to sign in from
|
|
75
|
+
// the endpoint's own 401 (`@nestjs/mcp`'s `auth` option).
|
|
76
|
+
break;
|
|
69
77
|
}
|
|
70
78
|
const Exception = status === 409 ? ConflictException : status === 403 ? ForbiddenException : UnauthorizedException;
|
|
71
79
|
// Nest builds the body: `error` is the code (the description) or the status phrase, then `errorCode`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs/authentication",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Nest - modern, fast, powerful node.js web framework (@authentication)",
|
|
5
5
|
"author": "Kamil Mysliwiec",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,26 +41,26 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@apollo/server": "5.5.1",
|
|
43
43
|
"@as-integrations/express5": "1.1.2",
|
|
44
|
-
"@commitlint/cli": "21.2.
|
|
45
|
-
"@commitlint/config-angular": "21.2.
|
|
44
|
+
"@commitlint/cli": "21.2.4",
|
|
45
|
+
"@commitlint/config-angular": "21.2.4",
|
|
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
49
|
"@nestjs/authorization": "0.1.0",
|
|
50
|
-
"@nestjs/common": "12.
|
|
51
|
-
"@nestjs/core": "12.
|
|
52
|
-
"@nestjs/drizzle": "0.0.
|
|
50
|
+
"@nestjs/common": "12.3.2",
|
|
51
|
+
"@nestjs/core": "12.3.2",
|
|
52
|
+
"@nestjs/drizzle": "0.0.2",
|
|
53
53
|
"@nestjs/graphql": "14.0.1",
|
|
54
|
-
"@nestjs/idempotency": "0.0.
|
|
54
|
+
"@nestjs/idempotency": "0.0.3",
|
|
55
55
|
"@nestjs/mail": "0.0.1",
|
|
56
|
-
"@nestjs/microservices": "12.
|
|
57
|
-
"@nestjs/platform-express": "12.
|
|
58
|
-
"@nestjs/platform-fastify": "12.
|
|
59
|
-
"@nestjs/platform-ws": "12.
|
|
60
|
-
"@nestjs/resilience": "0.0.
|
|
61
|
-
"@nestjs/testing": "12.
|
|
56
|
+
"@nestjs/microservices": "12.3.2",
|
|
57
|
+
"@nestjs/platform-express": "12.3.2",
|
|
58
|
+
"@nestjs/platform-fastify": "12.3.2",
|
|
59
|
+
"@nestjs/platform-ws": "12.3.2",
|
|
60
|
+
"@nestjs/resilience": "0.0.3",
|
|
61
|
+
"@nestjs/testing": "12.3.2",
|
|
62
62
|
"@nestjs/throttler": "6.7.1",
|
|
63
|
-
"@nestjs/websockets": "12.
|
|
63
|
+
"@nestjs/websockets": "12.3.2",
|
|
64
64
|
"@types/node": "24.19.2",
|
|
65
65
|
"@types/pg": "8.23.1",
|
|
66
66
|
"@types/supertest": "7.2.1",
|
|
@@ -69,18 +69,18 @@
|
|
|
69
69
|
"graphql": "16.14.2",
|
|
70
70
|
"husky": "9.1.7",
|
|
71
71
|
"lint-staged": "17.6.0",
|
|
72
|
-
"oxlint": "1.
|
|
72
|
+
"oxlint": "1.91.0",
|
|
73
73
|
"pg": "8.23.1",
|
|
74
74
|
"prettier": "3.9.10",
|
|
75
75
|
"reflect-metadata": "0.2.2",
|
|
76
|
-
"release-it": "21.1.
|
|
76
|
+
"release-it": "21.1.2",
|
|
77
77
|
"rxjs": "7.8.2",
|
|
78
78
|
"supertest": "7.3.1",
|
|
79
79
|
"typeorm": "1.1.2",
|
|
80
80
|
"typescript": "7.0.2",
|
|
81
|
-
"vite": "8.3.
|
|
82
|
-
"vitest": "5.0
|
|
83
|
-
"ws": "8.22.
|
|
81
|
+
"vite": "8.3.5",
|
|
82
|
+
"vitest": "5.1.0",
|
|
83
|
+
"ws": "8.22.1"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"@nestjs/common": "^12.0.0",
|