@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.
- package/CHANGELOG.md +248 -0
- package/README.md +212 -7
- package/dist/auth.module.d.ts +11 -1
- package/dist/auth.module.d.ts.map +1 -1
- package/dist/auth.module.js +92 -12
- package/dist/auth.module.js.map +1 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/decorators/require-scopes.decorator.d.ts +2 -0
- package/dist/decorators/require-scopes.decorator.d.ts.map +1 -0
- package/dist/decorators/require-scopes.decorator.js +8 -0
- package/dist/decorators/require-scopes.decorator.js.map +1 -0
- package/dist/guards/create-auth-guard.d.ts.map +1 -1
- package/dist/guards/create-auth-guard.js +3 -6
- package/dist/guards/create-auth-guard.js.map +1 -1
- package/dist/guards/csrf.guard.d.ts +4 -0
- package/dist/guards/csrf.guard.d.ts.map +1 -1
- package/dist/guards/csrf.guard.js +33 -5
- package/dist/guards/csrf.guard.js.map +1 -1
- package/dist/guards/jwt-auth.guard.d.ts +2 -1
- package/dist/guards/jwt-auth.guard.d.ts.map +1 -1
- package/dist/guards/jwt-auth.guard.js +4 -0
- package/dist/guards/jwt-auth.guard.js.map +1 -1
- package/dist/guards/permission.guard.js +2 -2
- package/dist/guards/permission.guard.js.map +1 -1
- package/dist/guards/scope.guard.d.ts +8 -0
- package/dist/guards/scope.guard.d.ts.map +1 -0
- package/dist/guards/scope.guard.js +53 -0
- package/dist/guards/scope.guard.js.map +1 -0
- package/dist/guards/tenant.guard.d.ts.map +1 -1
- package/dist/guards/tenant.guard.js +3 -0
- package/dist/guards/tenant.guard.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/api-key-repository.interface.d.ts +3 -0
- package/dist/interfaces/api-key-repository.interface.d.ts.map +1 -1
- package/dist/interfaces/refresh-retry-cache.interface.d.ts +6 -0
- package/dist/interfaces/refresh-retry-cache.interface.d.ts.map +1 -0
- package/dist/interfaces/refresh-retry-cache.interface.js +3 -0
- package/dist/interfaces/refresh-retry-cache.interface.js.map +1 -0
- package/dist/middleware/realm.middleware.d.ts +4 -2
- package/dist/middleware/realm.middleware.d.ts.map +1 -1
- package/dist/middleware/realm.middleware.js +50 -34
- package/dist/middleware/realm.middleware.js.map +1 -1
- package/dist/resolvers/base-auth.resolver.d.ts.map +1 -1
- package/dist/resolvers/base-auth.resolver.js +0 -15
- package/dist/resolvers/base-auth.resolver.js.map +1 -1
- package/dist/services/auth.service.d.ts +1 -3
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +22 -13
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/in-memory-refresh-retry-cache.d.ts +11 -0
- package/dist/services/in-memory-refresh-retry-cache.d.ts.map +1 -0
- package/dist/services/in-memory-refresh-retry-cache.js +45 -0
- package/dist/services/in-memory-refresh-retry-cache.js.map +1 -0
- package/dist/services/refresh-token.service.d.ts +13 -14
- package/dist/services/refresh-token.service.d.ts.map +1 -1
- package/dist/services/refresh-token.service.js +112 -33
- package/dist/services/refresh-token.service.js.map +1 -1
- package/dist/services/verification.service.d.ts +2 -1
- package/dist/services/verification.service.d.ts.map +1 -1
- package/dist/services/verification.service.js +38 -8
- package/dist/services/verification.service.js.map +1 -1
- package/dist/strategies/api-key.strategy.d.ts +6 -1
- package/dist/strategies/api-key.strategy.d.ts.map +1 -1
- package/dist/strategies/api-key.strategy.js +40 -6
- package/dist/strategies/api-key.strategy.js.map +1 -1
- package/dist/strategies/external-jwt.strategy.d.ts +19 -0
- package/dist/strategies/external-jwt.strategy.d.ts.map +1 -0
- package/dist/strategies/external-jwt.strategy.js +124 -0
- package/dist/strategies/external-jwt.strategy.js.map +1 -0
- package/dist/strategies/jwt.strategy.d.ts +3 -1
- package/dist/strategies/jwt.strategy.d.ts.map +1 -1
- package/dist/strategies/jwt.strategy.js +16 -11
- package/dist/strategies/jwt.strategy.js.map +1 -1
- package/dist/test-utils/execution-contexts.d.ts +7 -0
- package/dist/test-utils/execution-contexts.d.ts.map +1 -0
- package/dist/test-utils/execution-contexts.js +41 -0
- package/dist/test-utils/execution-contexts.js.map +1 -0
- package/dist/utils/cookies.d.ts +2 -0
- package/dist/utils/cookies.d.ts.map +1 -0
- package/dist/utils/cookies.js +34 -0
- package/dist/utils/cookies.js.map +1 -0
- package/dist/utils/execution-context.d.ts +3 -1
- package/dist/utils/execution-context.d.ts.map +1 -1
- package/dist/utils/execution-context.js +39 -4
- package/dist/utils/execution-context.js.map +1 -1
- package/dist/utils/provider-helpers.d.ts +6 -0
- package/dist/utils/provider-helpers.d.ts.map +1 -1
- package/dist/utils/provider-helpers.js +4 -0
- package/dist/utils/provider-helpers.js.map +1 -1
- package/dist/utils/verification-base-url.d.ts +2 -0
- package/dist/utils/verification-base-url.d.ts.map +1 -0
- package/dist/utils/verification-base-url.js +19 -0
- package/dist/utils/verification-base-url.js.map +1 -0
- 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` |
|
|
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)` |
|
|
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 {
|
|
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 =
|
|
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
|
|
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.
|