@ambushsoftworks/nestjs-auth-graphql 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +248 -0
  2. package/README.md +212 -7
  3. package/dist/auth.module.d.ts +11 -1
  4. package/dist/auth.module.d.ts.map +1 -1
  5. package/dist/auth.module.js +92 -12
  6. package/dist/auth.module.js.map +1 -1
  7. package/dist/constants.d.ts +1 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/constants.js +2 -1
  10. package/dist/constants.js.map +1 -1
  11. package/dist/decorators/require-scopes.decorator.d.ts +2 -0
  12. package/dist/decorators/require-scopes.decorator.d.ts.map +1 -0
  13. package/dist/decorators/require-scopes.decorator.js +8 -0
  14. package/dist/decorators/require-scopes.decorator.js.map +1 -0
  15. package/dist/guards/create-auth-guard.d.ts.map +1 -1
  16. package/dist/guards/create-auth-guard.js +3 -6
  17. package/dist/guards/create-auth-guard.js.map +1 -1
  18. package/dist/guards/csrf.guard.d.ts +4 -0
  19. package/dist/guards/csrf.guard.d.ts.map +1 -1
  20. package/dist/guards/csrf.guard.js +33 -5
  21. package/dist/guards/csrf.guard.js.map +1 -1
  22. package/dist/guards/jwt-auth.guard.d.ts +2 -1
  23. package/dist/guards/jwt-auth.guard.d.ts.map +1 -1
  24. package/dist/guards/jwt-auth.guard.js +4 -0
  25. package/dist/guards/jwt-auth.guard.js.map +1 -1
  26. package/dist/guards/permission.guard.js +2 -2
  27. package/dist/guards/permission.guard.js.map +1 -1
  28. package/dist/guards/scope.guard.d.ts +8 -0
  29. package/dist/guards/scope.guard.d.ts.map +1 -0
  30. package/dist/guards/scope.guard.js +53 -0
  31. package/dist/guards/scope.guard.js.map +1 -0
  32. package/dist/guards/tenant.guard.d.ts.map +1 -1
  33. package/dist/guards/tenant.guard.js +3 -0
  34. package/dist/guards/tenant.guard.js.map +1 -1
  35. package/dist/index.d.ts +6 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +6 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/interfaces/api-key-repository.interface.d.ts +3 -0
  40. package/dist/interfaces/api-key-repository.interface.d.ts.map +1 -1
  41. package/dist/interfaces/refresh-retry-cache.interface.d.ts +6 -0
  42. package/dist/interfaces/refresh-retry-cache.interface.d.ts.map +1 -0
  43. package/dist/interfaces/refresh-retry-cache.interface.js +3 -0
  44. package/dist/interfaces/refresh-retry-cache.interface.js.map +1 -0
  45. package/dist/middleware/realm.middleware.d.ts +4 -2
  46. package/dist/middleware/realm.middleware.d.ts.map +1 -1
  47. package/dist/middleware/realm.middleware.js +50 -34
  48. package/dist/middleware/realm.middleware.js.map +1 -1
  49. package/dist/resolvers/base-auth.resolver.d.ts.map +1 -1
  50. package/dist/resolvers/base-auth.resolver.js +0 -15
  51. package/dist/resolvers/base-auth.resolver.js.map +1 -1
  52. package/dist/services/auth.service.d.ts +1 -3
  53. package/dist/services/auth.service.d.ts.map +1 -1
  54. package/dist/services/auth.service.js +22 -13
  55. package/dist/services/auth.service.js.map +1 -1
  56. package/dist/services/in-memory-refresh-retry-cache.d.ts +11 -0
  57. package/dist/services/in-memory-refresh-retry-cache.d.ts.map +1 -0
  58. package/dist/services/in-memory-refresh-retry-cache.js +45 -0
  59. package/dist/services/in-memory-refresh-retry-cache.js.map +1 -0
  60. package/dist/services/refresh-token.service.d.ts +13 -14
  61. package/dist/services/refresh-token.service.d.ts.map +1 -1
  62. package/dist/services/refresh-token.service.js +112 -33
  63. package/dist/services/refresh-token.service.js.map +1 -1
  64. package/dist/services/verification.service.d.ts +2 -1
  65. package/dist/services/verification.service.d.ts.map +1 -1
  66. package/dist/services/verification.service.js +38 -8
  67. package/dist/services/verification.service.js.map +1 -1
  68. package/dist/strategies/api-key.strategy.d.ts +6 -1
  69. package/dist/strategies/api-key.strategy.d.ts.map +1 -1
  70. package/dist/strategies/api-key.strategy.js +40 -6
  71. package/dist/strategies/api-key.strategy.js.map +1 -1
  72. package/dist/strategies/external-jwt.strategy.d.ts +19 -0
  73. package/dist/strategies/external-jwt.strategy.d.ts.map +1 -0
  74. package/dist/strategies/external-jwt.strategy.js +124 -0
  75. package/dist/strategies/external-jwt.strategy.js.map +1 -0
  76. package/dist/strategies/jwt.strategy.d.ts +3 -1
  77. package/dist/strategies/jwt.strategy.d.ts.map +1 -1
  78. package/dist/strategies/jwt.strategy.js +16 -11
  79. package/dist/strategies/jwt.strategy.js.map +1 -1
  80. package/dist/test-utils/execution-contexts.d.ts +7 -0
  81. package/dist/test-utils/execution-contexts.d.ts.map +1 -0
  82. package/dist/test-utils/execution-contexts.js +41 -0
  83. package/dist/test-utils/execution-contexts.js.map +1 -0
  84. package/dist/utils/cookies.d.ts +2 -0
  85. package/dist/utils/cookies.d.ts.map +1 -0
  86. package/dist/utils/cookies.js +34 -0
  87. package/dist/utils/cookies.js.map +1 -0
  88. package/dist/utils/execution-context.d.ts +3 -1
  89. package/dist/utils/execution-context.d.ts.map +1 -1
  90. package/dist/utils/execution-context.js +39 -4
  91. package/dist/utils/execution-context.js.map +1 -1
  92. package/dist/utils/provider-helpers.d.ts +6 -0
  93. package/dist/utils/provider-helpers.d.ts.map +1 -1
  94. package/dist/utils/provider-helpers.js +4 -0
  95. package/dist/utils/provider-helpers.js.map +1 -1
  96. package/dist/utils/verification-base-url.d.ts +2 -0
  97. package/dist/utils/verification-base-url.d.ts.map +1 -0
  98. package/dist/utils/verification-base-url.js +19 -0
  99. package/dist/utils/verification-base-url.js.map +1 -0
  100. package/package.json +4 -2
package/CHANGELOG.md CHANGED
@@ -27,6 +27,254 @@ for buried obligations. **The marker was introduced in 0.9.0 and has not been
27
27
  retro-applied**, so it is reliable from 0.9.0 onward only. Convention adopted
28
28
  from `@ambushsoftworks/nestjs-payments-graphql`.
29
29
 
30
+ ## [0.12.0] - 2026-09-20
31
+
32
+ Ambush Desk asked for two things: verifying JWTs that other systems sign, and API
33
+ keys with scopes, expiry and prefixes. They also reported duplicate security
34
+ events, and checking that turned up a false one. Jobsites then filed two more:
35
+ a verification link that cannot follow a realm, and a password reset that
36
+ silently skips accounts it should not. See **Fixed**.
37
+
38
+ ### Fixed
39
+ - **Every signup, login and logout was logged twice.** `BaseAuthResolver` logged
40
+ `SIGNUP_SUCCESS`, `LOGIN_SUCCESS` and `LOGOUT_SUCCESS` after `AuthService` had
41
+ already logged each, so an `IAuthLogger` writing to an audit table stored two
42
+ rows per operation. `AuthService` keeps its copies: they also cover flows that
43
+ never reach the resolver. Reported by Ambush Desk, and reproduced end to end
44
+ before the fix.
45
+ - **A signup refused by `features.preventEnumerationOnSignup` logged
46
+ `SIGNUP_SUCCESS`** for an account that was never created, carrying the
47
+ synthetic response's random user ID. The resolver logged it without being able
48
+ to tell the signup apart from a real one; `AuthService`, which can, does not.
49
+ - **A password reset for an account with no password sent nothing, silently.**
50
+ Both `requestPasswordReset` and `resetPassword` tested `passwordHash == null`
51
+ and read it as "this user signed in with Google". It is also how an
52
+ operator-provisioned account starts, so an account created by an admin CLI
53
+ got no email — and the request returned the same generic success as a real
54
+ send, so the operator saw success for an account nobody could ever sign into.
55
+ Both guards now test for an actual social identity via the exported
56
+ `hasSocialIdentity(user)` (`googleId`, `facebookId` or `appleId`). A
57
+ social-only account behaves exactly as before. **The two guards have to agree**
58
+ — correcting only the first would email a credential the second refuses, which
59
+ is worse than the silence. A skip is now reported through `IAuthLogger` as
60
+ `PASSWORD_RESET_REQUESTED` with `result: 'SKIPPED_SOCIAL_ONLY'`; the public
61
+ response is deliberately unchanged, since `performRequestPasswordReset` is
62
+ reachable unauthenticated and a distinguishable return value would be an
63
+ enumeration oracle. Reported by jobsites.
64
+ - Consequence worth deciding on rather than discovering: a half-finished
65
+ signup row with no password and no social identity can now be claimed by
66
+ whoever controls the address. Previously it was unreachable, which is not
67
+ the same as safe.
68
+
69
+ ### Added
70
+ - **`createExternalJwtStrategy(name, options | { inject, useFactory })`**: a
71
+ Passport strategy for JWTs another system signs, with
72
+ `secretProvider(kid, request)`, required `algorithms`, `issuer`, `audience`,
73
+ `clockToleranceSec`, `maxTokenBytes` (default 8192), `mapClaims` and
74
+ `jwtFromRequest`. Separate from the package's `jwt` strategy: no realm check
75
+ and no user lookup. Every rejection fails softly, so it composes in
76
+ `createAuthGuard`. Requested by Ambush Desk.
77
+ - Unsafe configuration fails boot: missing or empty `algorithms`, `none`, an
78
+ algorithm jsonwebtoken does not know, HMAC mixed with asymmetric families
79
+ (which lets a public key be used as an HMAC secret), or one of the package's
80
+ own strategy names.
81
+ - The `{ inject, useFactory }` form wires constructor injection, so
82
+ `secretProvider` can use application services without a subclass.
83
+ - **API key scopes**: `@RequireScopes(...)` and `ScopeGuard`, with
84
+ `IApiKeyAccount.scopes?: string[]`. A key needs every listed scope, and
85
+ otherwise gets 403 `INSUFFICIENT_SCOPE` naming what the operation requires. A
86
+ key with no `scopes` has none. Signed-in people pass, since their access is
87
+ `PermissionGuard`'s job. Requested by Ambush Desk.
88
+ - **API key expiry**: `IApiKeyAccount.expiresAt?: Date | null`. A key past it is
89
+ refused with `401 API key has expired`. Requested by Ambush Desk.
90
+ - **`apiKey.headerName`**, such as `'X-API-Key'`, read before
91
+ `Authorization: Bearer`, which keeps working. `CsrfGuard` treats a request
92
+ carrying it as credentialed, as it already does for `Authorization`. Requested
93
+ by Ambush Desk.
94
+ - **`apiKey.prefix`**, such as `'ait_'`. A token without it is not an API key and
95
+ goes to the next strategy with no lookup; one with it that matches no key is
96
+ refused with `401 Invalid API key`, since it cannot be a JWT. That replaces the
97
+ requested `onUnknownKey: 'fail' | 'error'` switch. Requested by Ambush Desk.
98
+ - **`IApiKeyRepository.touchLastUsed?(id)`**, called once per successful match
99
+ and never for a refused key. It is not awaited, and a failure is logged rather
100
+ than failing the request. Requested by Ambush Desk.
101
+ - **`verification.baseUrl` accepts a resolver**: `string | ((realm?: string) =>
102
+ string)`. One deployment serving several brands needs a link that opens on the
103
+ site the user came from; the realm already reached the credential and was
104
+ dropped one line before the URL. It applies to both verification modes, since
105
+ the token link and the code page link are built from the same resolved base.
106
+ Purely additive — a string behaves exactly as before. Requested by jobsites.
107
+ - Called once per link and synchronous: resolve from a map, not a query.
108
+ - Must return a URL for `realm === undefined` too — a request exempted by
109
+ `realm.skip`, or a single-realm deployment, has no realm.
110
+ - Boot validation is necessarily weaker for a resolver: it is probed once with
111
+ `undefined`, because there is no request at startup to supply a realm. A
112
+ per-realm value is checked when the link is built, where token mode throws
113
+ (the link carries the only copy of the credential) and code mode warns and
114
+ sends without a link (the code is in the body).
115
+
116
+ ### Changed
117
+ - **The `jwt` strategy pins `algorithms: ['HS256']`**, the algorithm the module's
118
+ `JwtModule` signs with. Unpinned, jsonwebtoken 9 also accepted HS384 and HS512
119
+ tokens signed with `jwtSecret`. Tokens this package issues are unaffected.
120
+ - **`ApiKeyStrategy`'s constructor takes the `apiKey` options** as an optional
121
+ second argument. This matters only if you construct the strategy yourself.
122
+
123
+ ### Declined
124
+ - **Publishable, origin-restricted API keys.** A key an app embeds is public by
125
+ construction: it identifies an app rather than authenticating anyone, and
126
+ `Origin` is set freely by any non-browser client, so restricting it is abuse
127
+ control rather than authentication. Keeping them out means every credential
128
+ this package accepts is a secret one. Requested by Ambush Desk, which keeps
129
+ ingest keys as its own concept.
130
+
131
+ ### Documentation
132
+ - README: **External JWTs**; **API Key Authentication** extended with the prefix,
133
+ header, expiry, scopes and last-used tracking; `ScopeGuard` and
134
+ `@RequireScopes` in the guard and decorator tables; the new options; and
135
+ **Migrating to v0.12.0**.
136
+ - CLAUDE.md: **API Keys, Scopes and External JWTs**, and the rule that each
137
+ security event is logged once, by the layer that performs the action.
138
+ - README: **One API, several brands: `baseUrl` per realm**, the widened
139
+ `verification.baseUrl` row, and both new **Migrating to v0.12.0** notes.
140
+ - CLAUDE.md: base URL resolution is realm-aware through one seam, with the
141
+ warning not to answer it by handing the raw token to the email renderer; and
142
+ the rule that password reset tests for a social identity, never for a missing
143
+ password hash.
144
+
145
+ ### Testing
146
+ - **End-to-end** (`test/e2e/tokens-and-keys.e2e-spec.ts`), on both NestJS majors:
147
+ a customer token through `createAuthGuard(['customer-jwt'])` on an HTTP route
148
+ and in a resolver, both guard orders with staff tokens, and refusals for
149
+ another algorithm, an unknown key ID, another audience and an expired token.
150
+ For API keys: a staff JWT accepted with a prefix set, a key in `X-API-Key` with
151
+ its use recorded, an unknown prefixed key, an expired key, `@RequireScopes`
152
+ against keys and people, and the CSRF skip.
153
+ - **Signup, login and logout now run end to end through `BaseAuthResolver`**,
154
+ counting the events each produces, including a signup refused by
155
+ `preventEnumerationOnSignup`.
156
+ - **Per-realm links**, in `auth.service.verification-mode.spec.ts` against a real
157
+ `VerificationService`, and end to end in `auth-module.e2e-spec.ts` — the realm
158
+ is attached by `RealmMiddleware`, so only an assembled app proves it survives
159
+ the whole path from header to emailed link. Each test parses the rendered URL
160
+ and feeds the credential back through `resetPassword`, rather than asserting
161
+ that a send happened.
162
+ - **43 mutation checks**, each reintroducing one defect: every one failed a test.
163
+ The five added here include reverting each password-reset guard separately —
164
+ reverting only `resetPassword` is caught by the close-the-loop test, which is
165
+ the half-fix worth guarding against.
166
+
167
+ ## [0.11.0] - 2026-09-15
168
+
169
+ Ambush Desk asked for two things: refresh retries that survive a second API
170
+ instance, and guards that work on GraphQL subscriptions. Checking the first one
171
+ turned up a retry bug that had been there since the first release. See
172
+ **Fixed**.
173
+
174
+ ### Fixed
175
+ - **Guards failed on GraphQL subscriptions over `graphql-ws`.** Guards read
176
+ `context.req`, which a subscription does not have. With `GraphQLModule`'s
177
+ `subscriptions: { 'graphql-ws': true }`, Nest puts graphql-ws's connection
178
+ object there instead. With an app's own `useServer`, there is no `req` at all.
179
+ Run over a real `graphql-ws` client on 0.10.0, the guard chain
180
+ `CsrfGuard` → `createAuthGuard(['jwt'])` → `TenantGuard` → `PermissionGuard`
181
+ refused every subscription:
182
+ - with `GraphQLModule` subscriptions, `CsrfGuard` answered `CSRF validation failed`
183
+ - with an own server, it threw `TypeError`s: `Cannot read properties of
184
+ undefined (reading 'headers')` with a context function, `(reading 'req')`
185
+ without one
186
+
187
+ Guards now read the WebSocket upgrade request. Authentication, realm, tenant
188
+ and permission checks all work on subscriptions, and with no request to read,
189
+ guards answer 401 or 403. Reported by Ambush Desk; Ariadne had been working
190
+ around it with a subclass.
191
+ - **A retried token refresh was always rejected when the repository deletes
192
+ used tokens.** Refresh deletes the token it used and keeps the result for 10
193
+ seconds, so that a client that lost the response can retry. The retry then
194
+ looked up the used token to find the user, found nothing, and answered
195
+ `401 Invalid refresh token`. That covers every `IRefreshTokenRepository` whose
196
+ `delete` removes the row, which is what the method is for. The lookup has been
197
+ there since `v0.1.3`. The kept result now carries the user ID, and the retry
198
+ still re-checks account status. Reproduced on 0.10.0 before the fix, with a
199
+ repository that deletes.
200
+ - **A retry arriving between deleting the used token and keeping the result was
201
+ rejected.** The result is now kept first.
202
+
203
+ ### Added
204
+ - **`csrf.webSocket: 'reject' | 'skip'`**, default `'reject'`. Browsers cannot
205
+ send the CSRF header on a WebSocket, so this decides for cookie-authenticated
206
+ subscriptions: 403, or allow. `'skip'` is safe only with an `Origin` check
207
+ when the socket connects. A subscription whose upgrade request carries an
208
+ `Authorization` header is allowed either way. Any other value fails boot.
209
+ Requested by Ambush Desk.
210
+ - **The realm on subscriptions.** `RealmMiddleware` never sees a WebSocket
211
+ upgrade, so `JwtStrategy` resolves the realm from the upgrade request itself,
212
+ once per connection, with the same `realm.skip` and extractor rules. A realm
213
+ the app sets itself (in `onConnect`, say) is kept. Requested by Ambush Desk.
214
+ - **`isWebSocketContext(context)`, `requireRequestFromContext(context)` and
215
+ `readCookie(request, name)`**, exported. `readCookie` falls back to parsing
216
+ the `Cookie` header, which is all an upgrade request has.
217
+ - **`refreshRetryCacheInstance?: IRefreshRetryCache`.** A retry that reached a
218
+ different instance than the first refresh got `401` and a
219
+ `TOKEN_REUSE_DETECTED` event, because the retry cache lived in one process.
220
+ Requested by Ambush Desk.
221
+ - Implement `get`, `set(key, value, ttlMs)` and `delete` over a store the
222
+ instances share. The interface's JSDoc has a Postgres example.
223
+ - Entries hold live tokens, so for any store but the in-memory one they are
224
+ AES-256-GCM encrypted with a key derived from `encryptionKey`, which then
225
+ becomes required and must match on every instance.
226
+ - The package enforces expiry itself, and treats store errors as a miss.
227
+ - **`refreshGracePeriodSeconds`**, default 10. `0` turns retries off. Requested
228
+ by Ambush Desk.
229
+ - **`InMemoryRefreshRetryCache`**, the default, exported so a single-instance app
230
+ can pass it explicitly.
231
+ - **Boot warnings for in-memory defaults** that only work within one instance:
232
+ `rateLimiterInstance` (password-reset throttling) and
233
+ `refreshRetryCacheInstance`. Pass `new InMemoryRateLimiterService()` and
234
+ `new InMemoryRefreshRetryCache()` to confirm a single instance and silence
235
+ them. Requested by Ambush Desk.
236
+
237
+ ### Changed
238
+ - **`getRequestFromContext` returns `Record<string, any> | undefined`.** It finds a
239
+ subscription's upgrade request, and returns `undefined` when a GraphQL context
240
+ holds no request. It did before too, while typed as never doing so. TypeScript
241
+ now flags call sites that assume a request.
242
+ - **Cookie auth no longer requires cookie-parser.** The JWT cookie extractor and
243
+ `readRefreshTokenFromCookie` fall back to the `Cookie` header.
244
+ - **`CsrfGuard` checks `exemptOperations` before reading any header**, and
245
+ answers 403 rather than throwing when a GraphQL operation's context holds no
246
+ request. HTTP outcomes are unchanged.
247
+ - **`RefreshTokenService`'s retry cache methods are async and take the user ID:**
248
+ - `cacheRefreshResult(usedTokenHash, { userId, accessToken, refreshToken })`
249
+ - `getCachedRefreshResult(usedTokenHash)`, which resolves to that shape or `null`
250
+ - `invalidateCachedRefresh(usedTokenHash)`
251
+
252
+ The class no longer runs a cleanup timer, and `onModuleDestroy` is gone. This
253
+ only affects code that calls these methods directly or subclasses the service.
254
+ Implement `IRefreshRetryCache` instead.
255
+
256
+ ### Documentation
257
+ - **CLAUDE.md described a `gracePeriodSeconds` option that never existed**, and a
258
+ Redis subclass of `RefreshTokenService` that could not work. It overrode the
259
+ synchronous cache methods with async ones, and the truthy Promise would have
260
+ sent every refresh down the cached branch. Both are replaced by the port's real
261
+ design.
262
+ - README: **Refresh Retries**, **GraphQL Subscriptions**, **Migrating to
263
+ v0.11.0**, and rows for the new options and utilities.
264
+
265
+ ### Testing
266
+ - **End-to-end subscriptions.** The suite drives real `graphql-ws` clients
267
+ against three server setups on both NestJS majors: `GraphQLModule`-managed, an
268
+ own `useServer` with a context, and one without. Against each, it checks
269
+ delivery, a missing cookie, a wrong realm, a missing permission, another
270
+ tenant, CSRF `'reject'` and `'skip'`, and a bearer token on the upgrade.
271
+ - **Refresh retries end to end:**
272
+ - a retry gets the same pair
273
+ - two instances sharing a store
274
+ - without a shared store: `TOKEN_REUSE_DETECTED`
275
+ - after the grace period: 401
276
+ - a user suspended between the refresh and its retry is refused
277
+
30
278
  ## [0.10.0] - 2026-09-15
31
279
 
32
280
  Ambush Desk, a new consumer on NestJS 11, asked for routes that need no realm, a
package/README.md CHANGED
@@ -13,6 +13,7 @@ Production-grade authentication module for NestJS GraphQL + REST APIs. JWT, cook
13
13
  - [Multi-Tenancy](#multi-tenancy)
14
14
  - [Realm-Based Identity Isolation](#realm-based-identity-isolation)
15
15
  - [API Key Authentication](#api-key-authentication)
16
+ - [External JWTs](#external-jwts)
16
17
  - [Email System](#email-system)
17
18
  - [Verification Modes](#verification-modes)
18
19
  - [OAuth](#oauth)
@@ -20,6 +21,7 @@ Production-grade authentication module for NestJS GraphQL + REST APIs. JWT, cook
20
21
  - [Password Policy](#password-policy)
21
22
  - [Lifecycle Hooks](#lifecycle-hooks)
22
23
  - [Account Status](#account-status)
24
+ - [Refresh Retries](#refresh-retries)
23
25
  - [JWT Validation Modes](#jwt-validation-modes)
24
26
  - [JWT Payload Factory](#jwt-payload-factory)
25
27
  - [Configuration Reference](#configuration-reference)
@@ -35,8 +37,11 @@ Production-grade authentication module for NestJS GraphQL + REST APIs. JWT, cook
35
37
  - [Optional Instance Options](#optional-instance-options)
36
38
  - [Decorators](#decorators)
37
39
  - [Guards](#guards)
40
+ - [GraphQL Subscriptions](#graphql-subscriptions)
38
41
  - [Utilities](#utilities)
39
42
  - [Security Features](#security-features)
43
+ - [Migrating to v0.12.0](#migrating-to-v0120)
44
+ - [Migrating to v0.11.0](#migrating-to-v0110)
40
45
  - [Migrating to v0.10.0](#migrating-to-v0100)
41
46
  - [Migrating to v0.9.0](#migrating-to-v090)
42
47
  - [License](#license)
@@ -556,6 +561,73 @@ The `ApiKeyStrategy` hashes the bearer token with SHA-256 and calls `findByKeyHa
556
561
 
557
562
  Before 0.10.0 the API key strategy rejected an unknown bearer token itself, which stopped the chain: `['api-key', 'jwt']` rejected every JWT.
558
563
 
564
+ #### Key options
565
+
566
+ | Option | Example | Effect |
567
+ |--------|---------|--------|
568
+ | `apiKey.headerName` | `'X-API-Key'` | A header carrying the raw key, read before `Authorization: Bearer`, which keeps working. `CsrfGuard` treats a request carrying it as credentialed, as it already does for `Authorization` |
569
+ | `apiKey.prefix` | `'ait_'` | The prefix every key you issue starts with. See below |
570
+
571
+ Both are read only with `apiKeyRepositoryInstance`, and `apiKey.prefix` without it warns at boot. `headerName` must be a header name other than `Authorization`, and `prefix` must not be empty -- an empty one matches every token, so every unknown bearer token, JWTs included, would be refused. Boot fails on either.
572
+
573
+ **A prefix makes an unknown key unambiguous.** Without one, the strategy cannot tell a mistyped key from a JWT:
574
+
575
+ | Token | Without `prefix` | With `prefix` |
576
+ |-------|------------------|---------------|
577
+ | Does not start with the prefix (a JWT, say) | Looked up, then passed to the next strategy | Passed to the next strategy, with no lookup |
578
+ | Starts with it, matches no key | Passed to the next strategy; a single-strategy guard answers a generic 401 | `401 Invalid API key` -- it cannot be a JWT, so no other strategy tries it |
579
+ | Matches a key | Authenticated, unless the key is inactive or expired | The same |
580
+
581
+ #### Expiry, scopes and last use
582
+
583
+ `IApiKeyAccount` carries optional `scopes?: string[]` and `expiresAt?: Date | null`, and `IApiKeyRepository` may implement `touchLastUsed?(id)`. Existing repositories keep working.
584
+
585
+ - **Expiry.** A key whose `expiresAt` has passed is refused with `401 API key has expired`, and no other strategy tries it. A key without one never expires.
586
+ - **Scopes.** Mark the operation and register `ScopeGuard` after your authentication guard:
587
+
588
+ ```typescript
589
+ @Get('tickets')
590
+ @UseGuards(createAuthGuard(['api-key', 'jwt']), ScopeGuard)
591
+ @RequireScopes('tickets:read')
592
+ listTickets() { /* ... */ }
593
+ ```
594
+
595
+ A key must hold every listed scope. One that does not gets 403 with `{ message: 'Insufficient scope', code: 'INSUFFICIENT_SCOPE', requiredScopes, statusCode: 403 }`, naming what the operation requires. A key with no `scopes` holds none. **Signed-in people pass**: scopes restrict keys, while a person's access is `PermissionGuard`'s job. With no authenticated principal at all, `ScopeGuard` answers 401 -- which is why it goes after the auth guard.
596
+ - **Last use.** `touchLastUsed(id)` is called once per successful match, and only then -- never for a key that is refused. It is not awaited: a failure is logged and never fails the request.
597
+
598
+ ### External JWTs
599
+
600
+ `createExternalJwtStrategy(name, options)` verifies tokens another system signs -- each app's backend signing for its own users, say. It registers a separate Passport strategy under `name`, with no realm check and no user lookup, so the package's own `jwt` strategy is untouched.
601
+
602
+ ```typescript
603
+ export const CustomerJwtStrategy = createExternalJwtStrategy('customer-jwt', {
604
+ inject: [AppSecretsService], // optional; pass a plain options object instead
605
+ useFactory: (secrets: AppSecretsService) => ({
606
+ algorithms: ['HS256'], // required
607
+ audience: 'ambush-desk',
608
+ secretProvider: (kid) => secrets.findSecret(kid),
609
+ mapClaims: (claims) => ({ id: claims.sub, app: claims.app, segments: claims.segments }),
610
+ }),
611
+ });
612
+
613
+ // providers: [CustomerJwtStrategy]
614
+ // @UseGuards(createAuthGuard(['customer-jwt']))
615
+ ```
616
+
617
+ | Option | Default | Description |
618
+ |--------|---------|-------------|
619
+ | `secretProvider(kid, request)` | -- | The key that verifies a token: a shared secret for `HS*`, a PEM public key for `RS*`, `PS*` and `ES*`. May be async. `null` rejects the token; so does a throw, which is logged |
620
+ | `algorithms` | -- | **Required.** The algorithms the issuer signs with |
621
+ | `issuer`, `audience` | -- | Required `iss` / `aud`, when set |
622
+ | `clockToleranceSec` | `0` | Clock skew allowed on `exp` and `nbf` |
623
+ | `maxTokenBytes` | `8192` | A longer token is refused before anything parses it |
624
+ | `mapClaims(payload)` | the payload | Builds `request.user`, arrays and custom claims intact. `null`, `undefined` or a throw rejects the token |
625
+ | `jwtFromRequest(request)` | `Authorization: Bearer` | Where the token comes from |
626
+
627
+ **Algorithms are pinned, one family at a time.** Boot fails with `InvalidAuthConfigException` for missing or empty `algorithms`, for `none`, for an algorithm jsonwebtoken does not know, and for HMAC (`HS*`) mixed with asymmetric algorithms -- the mix that lets a public key be used as an HMAC secret. `'jwt'` and `'api-key'` are refused as names, since Passport would replace the package's own strategy.
628
+
629
+ **Every rejection fails softly**, so the strategy composes: `createAuthGuard(['customer-jwt', 'jwt'])` accepts either kind of token, in either order, and answers 401 when both fail.
630
+
559
631
  ### Email System
560
632
 
561
633
  The email system uses a composable architecture: a **sender** (transport) and a **template renderer** (HTML generation).
@@ -685,6 +757,40 @@ variable (deprecated — logged once). With neither, code-mode emails carry no
685
757
  link and nothing is logged: the code is in the body, so no link is needed. The
686
758
  code itself is never placed in a query string in either mode.
687
759
 
760
+ #### One API, several brands: `baseUrl` per realm
761
+
762
+ With [realms](#realm-based-identity-isolation) enabled, one deployment serves
763
+ several frontends and a link must open on the site the user actually came from.
764
+ `baseUrl` accepts a resolver as well as a string; it is called with the realm of
765
+ the request the credential is being issued for:
766
+
767
+ ```typescript
768
+ const SITES: Record<string, string> = {
769
+ 'site-a': 'https://a.example.com',
770
+ 'site-b': 'https://b.example.com',
771
+ };
772
+
773
+ verification: {
774
+ baseUrl: (realm?: string) => SITES[realm ?? ''] ?? 'https://www.example.com',
775
+ },
776
+ ```
777
+
778
+ It applies to both modes — the token link and the code page link — because both
779
+ are built from the same resolved base. It is called once per link, so resolve
780
+ from a map rather than a query, and it must be synchronous.
781
+
782
+ **Boot validation is weaker for a resolver, by necessity.** A string is checked
783
+ in full at startup. A resolver is only called once, with `undefined`, so the
784
+ realm-less case is checked and a per-realm return value is not — there is no
785
+ request at startup to supply a realm. What it returns for a specific realm is
786
+ checked when the link is built, and the two modes differ exactly as they do
787
+ elsewhere: token mode throws, because the link carries the only copy of the
788
+ credential; code mode logs a warning and sends the email without a link,
789
+ because the code is in the body.
790
+
791
+ Returning a value for `realm === undefined` is required. A request exempted by
792
+ `realm.skip`, or a single-realm deployment, has no realm.
793
+
688
794
  **Custom `IEmailService` implementations.** Both send methods receive the
689
795
  credential and its URL, and the mode determines which one is actionable:
690
796
 
@@ -926,6 +1032,35 @@ It must be synchronous and pure: it runs on every authenticated request, against
926
1032
 
927
1033
  **Your repository must return inactive users.** `findById` and `findByEmail` should return suspended and soft-deleted users like any other. A repository that hides them turns `ACCOUNT_INACTIVE` into a "not found" failure, and the package can no longer tell the client why.
928
1034
 
1035
+ ### Refresh Retries
1036
+
1037
+ Refresh tokens rotate: each refresh deletes the token it used. A client that loses the response — a dropped connection, a timeout, an HTTP client that retries — sends the used token again, which looks exactly like a stolen token being replayed. For `refreshGracePeriodSeconds` (default 10) the package remembers the result and answers the retry with the same token pair. `0` turns this off.
1038
+
1039
+ **One instance:** nothing to do.
1040
+
1041
+ **More than one instance:** give every instance the same store, or a retry that reaches a different instance gets `401` and signs the user out:
1042
+
1043
+ ```typescript
1044
+ AuthModule.forRootAsync({
1045
+ inject: [PgRefreshRetryCache, PgRateLimiter, ConfigService],
1046
+ useFactory: (retryCache, rateLimiter, config) => ({
1047
+ // ...required options
1048
+ refreshRetryCacheInstance: retryCache,
1049
+ encryptionKey: config.getOrThrow('ENCRYPTION_KEY'), // required with a shared store, same on every instance
1050
+ rateLimiterInstance: rateLimiter, // password-reset throttling needs sharing for the same reason
1051
+ }),
1052
+ })
1053
+ ```
1054
+
1055
+ - Implement `IRefreshRetryCache` (`get`, `set(key, value, ttlMs)`, `delete`) over Redis, Postgres, or any store the instances share. Its JSDoc has a Postgres example.
1056
+ - The values are AES-256-GCM ciphertext, because they hold live tokens. Boot fails with `InvalidAuthConfigException` when `encryptionKey` is missing.
1057
+ - The package enforces expiry itself, so the store's TTL can be approximate.
1058
+ - A store that errors costs retries, never the refresh itself.
1059
+
1060
+ At boot the package warns for each in-memory default in use, `rateLimiterInstance` and `refreshRetryCacheInstance`, because it cannot tell how many instances run. Pass `new InMemoryRateLimiterService()` and `new InMemoryRefreshRetryCache()` explicitly to confirm a single instance and silence them.
1061
+
1062
+ Before 0.11.0 a retry got `401` whenever `IRefreshTokenRepository.delete` removed the used token, even on a single instance.
1063
+
929
1064
  ### JWT Validation Modes
930
1065
 
931
1066
  - **`'full'`** (default) -- Calls `userRepository.findById()` on every request, rejecting the request if the user no longer exists or is no longer active (see [Account Status](#account-status)).
@@ -987,6 +1122,9 @@ These sit beside `useFactory`, not in the object it returns.
987
1122
  | `encryptionKey` | `string` | -- | 32-byte hex string for AES-256-GCM (OAuth token encryption) |
988
1123
  | `bruteForce.ipRateLimit` | `{ maxAttempts, windowMs }` | -- | IP rate-limit policy for `checkIpRateLimit`. When set, `rateLimiterInstance` MUST also be provided explicitly (boot fails with `InvalidAuthConfigException` otherwise). See [IP Rate Limiting](#ip-rate-limiting). |
989
1124
  | `realm.skip` | `(path, request) => boolean` | -- | Requests that need no realm, such as health checks and webhooks. Read only with `realmExtractorInstance`. See [Realm-Based Identity Isolation](#realm-based-identity-isolation) |
1125
+ | `refreshGracePeriodSeconds` | `number` | `10` | How long a retried refresh gets the same token pair. `0` turns retries off. See [Refresh Retries](#refresh-retries) |
1126
+ | `apiKey.headerName` | `string` | -- | A header carrying the raw API key, e.g. `'X-API-Key'`, read before `Authorization`. See [API Key Authentication](#api-key-authentication) |
1127
+ | `apiKey.prefix` | `string` | -- | The prefix every API key you issue starts with, e.g. `'ait_'`. Makes an unknown key unambiguous. See [API Key Authentication](#api-key-authentication) |
990
1128
 
991
1129
  ### Feature Flags (`features`)
992
1130
 
@@ -1042,6 +1180,7 @@ Separate HMAC secrets for security isolation. All fall back to `jwtSecret` if no
1042
1180
  | `headerName` | `string` | `'X-Requested-With'` | Header to check |
1043
1181
  | `requireInProduction` | `boolean` | `true` | Require CSRF header in production |
1044
1182
  | `exemptOperations` | `string[]` | `[]` | GraphQL operations to skip |
1183
+ | `webSocket` | `'reject' \| 'skip'` | `'reject'` | Cookie-authenticated GraphQL subscriptions: refuse with 403, or allow. `'skip'` needs an `Origin` check when the socket connects. See [GraphQL Subscriptions](#graphql-subscriptions) |
1045
1184
 
1046
1185
  ### Composable Email (`email`)
1047
1186
 
@@ -1060,7 +1199,7 @@ When `email` is provided, it takes precedence over `emailServiceInstance`.
1060
1199
  |--------|------|---------|-------------|
1061
1200
  | `tokenLength` | `number` | `64` | Token length in bytes (token mode) |
1062
1201
  | `tokenExpiresInMinutes` | `number` | `60` | Token expiration (token mode) |
1063
- | `baseUrl` | `string` | -- | Absolute http(s) base URL for verification/reset links. **Required when `verificationMode` is `'token'`** (validated at boot); optional in code mode |
1202
+ | `baseUrl` | `string \| (realm?: string) => string` | — | Absolute http(s) base URL for verification/reset links, or a resolver called with the request's realm. **Required when `verificationMode` is `'token'`** (validated at boot); optional in code mode |
1064
1203
 
1065
1204
  ### Optional Instance Options
1066
1205
 
@@ -1079,6 +1218,7 @@ When `email` is provided, it takes precedence over `emailServiceInstance`.
1079
1218
  | `jwtPayloadFactoryInstance` | `IJwtPayloadFactory` | `DefaultJwtPayloadFactory` |
1080
1219
  | `apiKeyRepositoryInstance` | `IApiKeyRepository` | `null` |
1081
1220
  | `rateLimiterInstance` | `IRateLimiter` | `InMemoryRateLimiterService` (Note: this fallback does NOT apply when `bruteForce.ipRateLimit` is configured — boot fails with `InvalidAuthConfigException` and an explicit `rateLimiterInstance` is required. See [IP Rate Limiting](#ip-rate-limiting) above.) |
1221
+ | `refreshRetryCacheInstance` | `IRefreshRetryCache` | `InMemoryRefreshRetryCache`, which answers retries on the same instance only. A shared store requires `encryptionKey`. See [Refresh Retries](#refresh-retries) |
1082
1222
  | `realmExtractorInstance` | `IRealmExtractor` | `null` (realms disabled) |
1083
1223
 
1084
1224
  ---
@@ -1091,6 +1231,7 @@ When `email` is provided, it takes precedence over `emailServiceInstance`.
1091
1231
  | `@PublicEndpoint()` | Method/Class | Skip authentication + tenant resolution (combines `@Public()` + `@SkipTenant()`) |
1092
1232
  | `@SkipTenant()` | Method/Class | Skip tenant resolution |
1093
1233
  | `@RequirePermissions('p1', 'p2')` | Method/Class | Require specific permissions |
1234
+ | `@RequireScopes('s1', 's2')` | Method/Class | Require API key scopes, enforced by `ScopeGuard`. People are unaffected. See [API Key Authentication](#api-key-authentication) |
1094
1235
  | `@CurrentUser()` | Parameter | Inject authenticated user |
1095
1236
  | `@CurrentTenant()` | Parameter | Inject resolved tenant context |
1096
1237
  | `@CurrentRealm()` | Parameter | Inject resolved realm from request context |
@@ -1104,6 +1245,7 @@ When `email` is provided, it takes precedence over `emailServiceInstance`.
1104
1245
  | `CsrfGuard` | CSRF header validation for cookie auth |
1105
1246
  | `TenantGuard` | Multi-tenant context resolution |
1106
1247
  | `PermissionGuard` | Permission checking against tenant context |
1248
+ | `ScopeGuard` | `@RequireScopes()` enforcement for API keys; register it after the authentication guard |
1107
1249
  | `JwtAuthGuard` | JWT-only guard for GraphQL and HTTP routes (prefer `createAuthGuard` for several strategies or `@Public()`) |
1108
1250
 
1109
1251
  ### Recommended Guard Chains
@@ -1114,8 +1256,9 @@ When realm support is enabled, `RealmMiddleware` runs before all guards (as Nest
1114
1256
  // JWT only
1115
1257
  { provide: APP_GUARD, useClass: createAuthGuard(['jwt'], { allowPublic: true }) }
1116
1258
 
1117
- // JWT + API keys
1118
- { provide: APP_GUARD, useClass: createAuthGuard(['jwt', 'api-key'], { allowPublic: true }) }
1259
+ // JWT + API keys, with scopes enforced on the keys
1260
+ { provide: APP_GUARD, useClass: createAuthGuard(['jwt', 'api-key'], { allowPublic: true }) },
1261
+ { provide: APP_GUARD, useClass: ScopeGuard },
1119
1262
 
1120
1263
  // Full stack with cookie auth + multi-tenancy
1121
1264
  { provide: APP_GUARD, useClass: CsrfGuard },
@@ -1124,13 +1267,45 @@ When realm support is enabled, `RealmMiddleware` runs before all guards (as Nest
1124
1267
  { provide: APP_GUARD, useClass: PermissionGuard },
1125
1268
  ```
1126
1269
 
1270
+ ### GraphQL Subscriptions
1271
+
1272
+ The guards also protect subscriptions over `graphql-ws`. A socket has no HTTP request, so they read the WebSocket upgrade request instead: the headers and cookies the client opened the connection with.
1273
+
1274
+ | Guard | On a subscription |
1275
+ |-------|-------------------|
1276
+ | `createAuthGuard`, `JwtAuthGuard` | Authenticates from the upgrade request's `Authorization` header or session cookie. `RealmMiddleware` never sees the upgrade, so the realm is resolved from it once per connection |
1277
+ | `TenantGuard`, `PermissionGuard` | Resolve the tenant and check permissions from the upgrade request |
1278
+ | `CsrfGuard` | Browsers cannot send the CSRF header on a WebSocket, so `csrf.webSocket` decides: `'reject'` (default) answers 403, `'skip'` allows. A connection with an `Authorization` header is allowed either way |
1279
+
1280
+ **Use `'skip'` only with an `Origin` check when the socket connects.** That check is what stops another site from opening a socket with your users' cookies.
1281
+
1282
+ With your own `graphql-ws` server, also pass the connection through as the context. Without it, guards receive no request and refuse every subscription with 401:
1283
+
1284
+ ```typescript
1285
+ useServer(
1286
+ {
1287
+ schema,
1288
+ context: (ctx) => ctx,
1289
+ onConnect: (ctx) => ALLOWED_ORIGINS.includes(ctx.extra.request.headers.origin ?? ''),
1290
+ },
1291
+ wsServer,
1292
+ );
1293
+ ```
1294
+
1295
+ With `GraphQLModule`'s `subscriptions: { 'graphql-ws': { onConnect } }`, the guards find the upgrade request on their own; put the same `onConnect` there.
1296
+
1297
+ `isWebSocketContext(context)` and `readCookie(request, name)` are exported for your own guards and connection handlers.
1298
+
1127
1299
  ## Utilities
1128
1300
 
1129
1301
  Helper functions exported for use in custom guards, middleware and loggers:
1130
1302
 
1131
1303
  | Function | Description |
1132
1304
  |----------|-------------|
1133
- | `getRequestFromContext(context)` | Extract request from `ExecutionContext` (handles GraphQL + HTTP) |
1305
+ | `getRequestFromContext(context)` | The request behind an `ExecutionContext`: HTTP, GraphQL, or a `graphql-ws` subscription's upgrade request. `undefined` when there is none |
1306
+ | `requireRequestFromContext(context)` | The same, but throws `UnauthorizedException` when there is no request |
1307
+ | `isWebSocketContext(context)` | Whether the context is a GraphQL subscription over a WebSocket |
1308
+ | `readCookie(request, name)` | A cookie's value from `request.cookies`, or parsed from the `Cookie` header (a WebSocket upgrade request has only the header) |
1134
1309
  | `extractIpAddress(request)` | Extract client IP (checks `clientIp`, `req.ip`, falls back to `'unknown'`) |
1135
1310
  | `isUserActiveByDefault(user)` | The default account-active predicate (see [Account Status](#account-status)) |
1136
1311
  | `emailForLog(email)`, `ipForLog(ip)` | An email or IP as it may appear in a log line — `emailHash=…` / `ipHash=…`, salted like the package's own logs |
@@ -1139,12 +1314,12 @@ Helper functions exported for use in custom guards, middleware and loggers:
1139
1314
  | `hashIp(ip, salt)` | HMAC-SHA256 IP hash (16 hex characters) used for rate-limit keys |
1140
1315
 
1141
1316
  ```typescript
1142
- import { getRequestFromContext, extractIpAddress } from '@ambushsoftworks/nestjs-auth-graphql';
1317
+ import { requireRequestFromContext, extractIpAddress } from '@ambushsoftworks/nestjs-auth-graphql';
1143
1318
 
1144
1319
  @Injectable()
1145
1320
  export class CustomGuard implements CanActivate {
1146
1321
  canActivate(context: ExecutionContext): boolean {
1147
- const request = getRequestFromContext(context);
1322
+ const request = requireRequestFromContext(context);
1148
1323
  const ip = extractIpAddress(request);
1149
1324
  // your logic
1150
1325
  return true;
@@ -1154,7 +1329,7 @@ export class CustomGuard implements CanActivate {
1154
1329
 
1155
1330
  ## Security Features
1156
1331
 
1157
- - **Refresh token rotation** with HMAC-SHA256 hashing and idempotent 10-second grace period
1332
+ - **Refresh token rotation** with HMAC-SHA256 hashing; a retried refresh gets the same pair for 10 seconds (configurable, shareable across instances, encrypted at rest)
1158
1333
  - **Bcrypt password hashing** with configurable rounds (default: 12)
1159
1334
  - **Account status enforcement** -- suspended and deleted users are rejected at login, on refresh and on every authenticated request (`ACCOUNT_INACTIVE`)
1160
1335
  - **AES-256-GCM encryption** for OAuth tokens at rest
@@ -1164,8 +1339,38 @@ export class CustomGuard implements CanActivate {
1164
1339
  - **`__Host-` cookie prefix** support for enhanced cookie security
1165
1340
  - **Separate HMAC secrets** for refresh tokens, verification codes, and OAuth state
1166
1341
  - **Realm-based identity isolation** with JWT realm claim validation, realm-scoped rate limiting and lockout, and auth flows that refuse a request without a realm
1342
+ - **Pinned JWT algorithms** -- the package's own `jwt` strategy verifies `HS256` only, and `createExternalJwtStrategy` requires an explicit `algorithms` list from one family (see [External JWTs](#external-jwts))
1343
+ - **API keys with scopes and expiry**, refused hard when they carry your prefix and match no key, so no other strategy accepts them
1167
1344
  - **No raw personal data in package logs** — log lines use the user ID or a salted hash (`emailHash=`, `ipHash=`, under `AUTH_IP_HASH_SALT` or `jwtSecret`), phone numbers keep only their last two digits, and the default `ConsoleAuthLogger` redacts security-event metadata. A custom `authLoggerInstance` receives the raw fields; pass them through `redactSecurityEventMetadata` to store them redacted.
1168
1345
 
1346
+ ## Migrating to v0.12.0
1347
+
1348
+ **Password reset now reaches accounts that have no password and no social login.** The guard on `requestPasswordReset` and `resetPassword` tested `passwordHash == null` and treated it as "this user signed in with Google". That is also how an operator-provisioned account starts, so an account created by an admin CLI was skipped silently — the request returned the same success message as a real send, and the reset would have been refused even if a code had arrived. Both guards now test for an actual social identity (`googleId`, `facebookId` or `appleId`), so a social-only account behaves exactly as before, and a password-less account with no social identity receives a reset code and can redeem it.
1349
+
1350
+ Two consequences worth deciding on rather than discovering. A half-finished signup row with no password and no social identity can now be claimed by whoever controls the address — previously it was unreachable, which is not the same as safe. And if you wrote a placeholder password hash to work around the old behaviour, you can drop it; `hasSocialIdentity` is exported if you want the same predicate.
1351
+
1352
+ The public response is unchanged in both cases. `requestPasswordReset` still returns the same generic message whether it sent, skipped or found nothing, because it is reachable unauthenticated and anything else would be an enumeration oracle. A skip is reported to your `IAuthLogger` instead, as `PASSWORD_RESET_REQUESTED` with `result: 'SKIPPED_SOCIAL_ONLY'`.
1353
+
1354
+ **`verification.baseUrl` accepts a resolver.** Purely additive — a string behaves exactly as before. See [`baseUrl` per realm](#one-api-several-brands-baseurl-per-realm).
1355
+
1356
+ **The `jwt` strategy now pins `HS256`.** That is the algorithm `JwtModule` signs with, so every token this package issues keeps working. A token signed with `jwtSecret` under HS384 or HS512 -- which jsonwebtoken accepted while nothing was pinned -- is now rejected. If another system mints tokens for the `jwt` strategy, check that it signs HS256, or give it its own strategy with [`createExternalJwtStrategy`](#external-jwts).
1357
+
1358
+ **Each security event is logged once.** `BaseAuthResolver` logged `SIGNUP_SUCCESS`, `LOGIN_SUCCESS` and `LOGOUT_SUCCESS` on top of `AuthService`, so an `IAuthLogger` writing to an audit table stored two rows per signup, login and logout. The service's copy is the one that remains: it also covers flows that never reach the resolver. Counts built on those events halve, and `LOGOUT_SUCCESS` metadata is now `{ userId }` -- the resolver's copy also carried `email`. `SIGNUP_ATTEMPT` and `LOGOUT_ALL`, which only the resolver logs, are unchanged.
1359
+
1360
+ **A signup refused by `features.preventEnumerationOnSignup` no longer logs `SIGNUP_SUCCESS`.** It used to log one, with the synthetic response's random user ID, for an account that was never created.
1361
+
1362
+ **API keys.** Nothing changes until you set `apiKey.prefix` or `apiKey.headerName`, or store `scopes` / `expiresAt`. `ApiKeyStrategy`'s constructor takes the `apiKey` options as an optional second argument, which matters only if you construct it yourself.
1363
+
1364
+ ## Migrating to v0.11.0
1365
+
1366
+ **`getRequestFromContext` may return `undefined`.** It now also finds a `graphql-ws` subscription's upgrade request, and returns `undefined` where there is no request at all. TypeScript flags each call that assumes a request. In a guard that cannot decide without one, use `requireRequestFromContext`.
1367
+
1368
+ **Subscriptions through `CsrfGuard`.** With cookie auth they answer 403 unless you set `csrf.webSocket: 'skip'`, together with an `Origin` check when the socket connects. They failed before 0.11.0 as well: with that same 403 on `GraphQLModule` subscriptions, or a `TypeError` on your own `graphql-ws` server. See [GraphQL Subscriptions](#graphql-subscriptions).
1369
+
1370
+ **Cookie auth no longer needs cookie-parser.** The access and refresh token cookies are read from the `Cookie` header when `request.cookies` is absent.
1371
+
1372
+ **More than one instance.** Set `refreshRetryCacheInstance` and `encryptionKey`, or retried refreshes that reach another instance sign users out; see [Refresh Retries](#refresh-retries). Boot now warns for each in-memory default in use. `RefreshTokenService`'s retry cache methods became async, which only matters if you call or override them.
1373
+
1169
1374
  ## Migrating to v0.10.0
1170
1375
 
1171
1376
  No code change is required. Check the items that apply to you.