@ambushsoftworks/nestjs-auth-graphql 0.15.1 → 0.16.0-rc.2
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 +160 -0
- package/README.md +385 -3
- package/dist/auth.module.d.ts +21 -0
- package/dist/auth.module.d.ts.map +1 -1
- package/dist/auth.module.js +104 -0
- package/dist/auth.module.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/dto/passkey.dto.d.ts +43 -0
- package/dist/dto/passkey.dto.d.ts.map +1 -0
- package/dist/dto/passkey.dto.js +5 -0
- package/dist/dto/passkey.dto.js.map +1 -0
- package/dist/enums/verification-type.enum.d.ts +3 -2
- package/dist/enums/verification-type.enum.d.ts.map +1 -1
- package/dist/enums/verification-type.enum.js +1 -0
- package/dist/enums/verification-type.enum.js.map +1 -1
- package/dist/exceptions/passkey.exceptions.d.ts +11 -0
- package/dist/exceptions/passkey.exceptions.d.ts.map +1 -0
- package/dist/exceptions/passkey.exceptions.js +29 -0
- package/dist/exceptions/passkey.exceptions.js.map +1 -0
- package/dist/exceptions/step-up.exceptions.d.ts +14 -0
- package/dist/exceptions/step-up.exceptions.d.ts.map +1 -0
- package/dist/exceptions/step-up.exceptions.js +41 -0
- package/dist/exceptions/step-up.exceptions.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/auth-lifecycle-hooks.interface.d.ts +2 -0
- package/dist/interfaces/auth-lifecycle-hooks.interface.d.ts.map +1 -1
- package/dist/interfaces/auth-logger.interface.d.ts +10 -1
- package/dist/interfaces/auth-logger.interface.d.ts.map +1 -1
- package/dist/interfaces/auth-logger.interface.js +9 -0
- package/dist/interfaces/auth-logger.interface.js.map +1 -1
- package/dist/interfaces/biometric-verifier.interface.d.ts.map +1 -1
- package/dist/interfaces/email-service.interface.d.ts +3 -0
- package/dist/interfaces/email-service.interface.d.ts.map +1 -1
- package/dist/interfaces/email-template-renderer.interface.d.ts +15 -0
- package/dist/interfaces/email-template-renderer.interface.d.ts.map +1 -1
- package/dist/interfaces/index.d.ts +1 -0
- package/dist/interfaces/index.d.ts.map +1 -1
- package/dist/interfaces/index.js +1 -0
- package/dist/interfaces/index.js.map +1 -1
- package/dist/interfaces/passkey-repository.interface.d.ts +48 -0
- package/dist/interfaces/passkey-repository.interface.d.ts.map +1 -0
- package/dist/interfaces/passkey-repository.interface.js +13 -0
- package/dist/interfaces/passkey-repository.interface.js.map +1 -0
- package/dist/passkey/passkey-json.types.d.ts +44 -0
- package/dist/passkey/passkey-json.types.d.ts.map +1 -0
- package/dist/passkey/passkey-json.types.js +3 -0
- package/dist/passkey/passkey-json.types.js.map +1 -0
- package/dist/passkey/webauthn-adapter.d.ts +104 -0
- package/dist/passkey/webauthn-adapter.d.ts.map +1 -0
- package/dist/passkey/webauthn-adapter.js +207 -0
- package/dist/passkey/webauthn-adapter.js.map +1 -0
- package/dist/repositories/in-memory-passkey.repository.d.ts +21 -0
- package/dist/repositories/in-memory-passkey.repository.d.ts.map +1 -0
- package/dist/repositories/in-memory-passkey.repository.js +72 -0
- package/dist/repositories/in-memory-passkey.repository.js.map +1 -0
- package/dist/resolvers/base-auth.resolver.d.ts +20 -0
- package/dist/resolvers/base-auth.resolver.d.ts.map +1 -1
- package/dist/resolvers/base-auth.resolver.js +104 -0
- package/dist/resolvers/base-auth.resolver.js.map +1 -1
- package/dist/services/auth.service.d.ts +5 -0
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +24 -0
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/biometric-challenge.service.d.ts.map +1 -1
- package/dist/services/biometric-challenge.service.js.map +1 -1
- package/dist/services/configurable-email.service.d.ts +3 -0
- package/dist/services/configurable-email.service.d.ts.map +1 -1
- package/dist/services/configurable-email.service.js +54 -0
- package/dist/services/configurable-email.service.js.map +1 -1
- package/dist/services/default-email-template-renderer.d.ts +16 -0
- package/dist/services/default-email-template-renderer.d.ts.map +1 -1
- package/dist/services/default-email-template-renderer.js +84 -0
- package/dist/services/default-email-template-renderer.js.map +1 -1
- package/dist/services/passkey.service.d.ts +104 -0
- package/dist/services/passkey.service.d.ts.map +1 -0
- package/dist/services/passkey.service.js +519 -0
- package/dist/services/passkey.service.js.map +1 -0
- package/dist/services/step-up-token.service.d.ts +15 -0
- package/dist/services/step-up-token.service.d.ts.map +1 -0
- package/dist/services/step-up-token.service.js +71 -0
- package/dist/services/step-up-token.service.js.map +1 -0
- package/dist/services/step-up.service.d.ts +50 -0
- package/dist/services/step-up.service.d.ts.map +1 -0
- package/dist/services/step-up.service.js +142 -0
- package/dist/services/step-up.service.js.map +1 -0
- package/dist/services/verification.service.d.ts +2 -2
- package/dist/services/verification.service.d.ts.map +1 -1
- package/dist/services/verification.service.js +5 -4
- package/dist/services/verification.service.js.map +1 -1
- package/dist/test-utils/software-authenticator.d.ts +37 -0
- package/dist/test-utils/software-authenticator.d.ts.map +1 -0
- package/dist/test-utils/software-authenticator.js +212 -0
- package/dist/test-utils/software-authenticator.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/passkey-repository.contract.d.ts +8 -0
- package/dist/testing/passkey-repository.contract.d.ts.map +1 -0
- package/dist/testing/passkey-repository.contract.js +248 -0
- package/dist/testing/passkey-repository.contract.js.map +1 -0
- package/dist/utils/passkey-rp.d.ts +13 -0
- package/dist/utils/passkey-rp.d.ts.map +1 -0
- package/dist/utils/passkey-rp.js +89 -0
- package/dist/utils/passkey-rp.js.map +1 -0
- package/package.json +7 -1
- package/testing/package.json +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -27,6 +27,166 @@ 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.16.0-rc.2] - 2026-10-01
|
|
31
|
+
|
|
32
|
+
Fixes from LiftIQ's pilot of rc.1 on a real Android Credential Manager. Still a
|
|
33
|
+
release candidate on `next`.
|
|
34
|
+
|
|
35
|
+
### Changed — breaking (for rc.1 adopters)
|
|
36
|
+
|
|
37
|
+
- **A rejected step-up proof is `STEP_UP_FAILED` (403), not `401
|
|
38
|
+
UNAUTHENTICATED`.** Clients read `UNAUTHENTICATED` as an expired session, so
|
|
39
|
+
an Apollo error link refreshed the token and re-sent `reauthenticate`: a wrong
|
|
40
|
+
password counted **twice** toward lockout, a passkey proof's retry always
|
|
41
|
+
failed (its challenge was spent), and a failed refresh signed the user out.
|
|
42
|
+
The message is unchanged. Do not map `STEP_UP_FAILED` to a token refresh.
|
|
43
|
+
- **Passkey sign-in failure is `PASSKEY_AUTHENTICATION_FAILED` (401)**, same
|
|
44
|
+
message, same answer for every reason. Its own code for the same reason: a
|
|
45
|
+
client still holding an old token would refresh and retry an assertion whose
|
|
46
|
+
challenge is spent.
|
|
47
|
+
- **The two passkey conflicts have codes:** `PASSKEY_ALREADY_REGISTERED` and
|
|
48
|
+
`CANNOT_REMOVE_LAST_AUTH_METHOD`, both 409, in the style of
|
|
49
|
+
`CANNOT_UNLINK_LAST_AUTH_METHOD`. Before, both were `CONFLICT` with English
|
|
50
|
+
text only.
|
|
51
|
+
- **The package emails the owner when a passkey is added or removed.** Through
|
|
52
|
+
the new optional `IEmailService.sendPasskeyAddedEmail(email, passkeyName,
|
|
53
|
+
addedAt)` and `sendPasskeyRemovedEmail(email, passkeyName, removedAt)`, when
|
|
54
|
+
the email service has them; `ConfigurableEmailService` does when its renderer
|
|
55
|
+
implements the new `renderPasskeyAddedEmail` / `renderPasskeyRemovedEmail`,
|
|
56
|
+
and `DefaultEmailTemplateRenderer` does. **If you send these from
|
|
57
|
+
`onPasskeyRegistered` / `onPasskeyRemoved`, stop, or owners get two** — or
|
|
58
|
+
turn the package's off with `passkey.emailNotifications: { added: false,
|
|
59
|
+
removed: false }`. rc.1 told adopters to wire the email themselves without
|
|
60
|
+
giving them anything to wire it to; a security notice the owner depends on
|
|
61
|
+
should not be opt-in.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- **`@ambushsoftworks/nestjs-auth-graphql/testing`**, a public entry with:
|
|
66
|
+
- `assertPasskeyRepositoryContract(factory)`: checks an `IPasskeyRepository`
|
|
67
|
+
adapter against the contract, against your real database, and rejects
|
|
68
|
+
naming the rule broken. The README pointed adopters at a spec file that is
|
|
69
|
+
not published; this replaces it. It is verified to catch a read-then-write
|
|
70
|
+
claim or counter update, a 32-bit counter column, an unmapped unique
|
|
71
|
+
violation, a dropped realm, hidden deactivated credentials, and expiry left
|
|
72
|
+
to a TTL.
|
|
73
|
+
- `SoftwareAuthenticator`, previously reachable only as a deep import into
|
|
74
|
+
`dist/` (which keeps working).
|
|
75
|
+
It is a published `testing/` folder rather than an `exports` map, because an
|
|
76
|
+
`exports` map would break existing deep imports.
|
|
77
|
+
- `passkey.emailNotifications: { added?, removed? }`, both default `true`,
|
|
78
|
+
validated at boot (a misspelt key would otherwise leave a notice on).
|
|
79
|
+
- Exceptions `StepUpFailedException`, `PasskeyAuthenticationFailedException`,
|
|
80
|
+
`PasskeyAlreadyRegisteredException`, `CannotRemoveLastAuthMethodException`.
|
|
81
|
+
|
|
82
|
+
### Documented
|
|
83
|
+
|
|
84
|
+
- **A step-up token is reusable until it expires, not single-use**, and a test
|
|
85
|
+
pins it. One re-authentication may cover several protected actions within
|
|
86
|
+
`stepUp.ttlSeconds` (default 300). The token is bound to user and realm and
|
|
87
|
+
is useless without that user's session.
|
|
88
|
+
|
|
89
|
+
## [0.16.0-rc.1] - 2026-10-01
|
|
90
|
+
|
|
91
|
+
Passkeys (WebAuthn), and step-up re-authentication to protect adding one.
|
|
92
|
+
|
|
93
|
+
**Release candidate**, published to the `next` dist-tag for a pilot on real
|
|
94
|
+
devices; `latest` stays on 0.15.x. Install with
|
|
95
|
+
`npm install @ambushsoftworks/nestjs-auth-graphql@next`.
|
|
96
|
+
|
|
97
|
+
### ⚠ External configuration required
|
|
98
|
+
|
|
99
|
+
Only if you enable passkeys. None of these is visible to a compiler or a test
|
|
100
|
+
suite; each fails as an opaque error on the device. Details in the README,
|
|
101
|
+
"Passkeys → Platform setup".
|
|
102
|
+
|
|
103
|
+
- **Install the optional peer** `@simplewebauthn/server@^14`. It needs
|
|
104
|
+
**Node.js 20+**; the package itself still supports 18 without passkeys. Boot
|
|
105
|
+
fails with the install command if it is missing.
|
|
106
|
+
- **Web:** serve the frontend over HTTPS on `passkey.rp.id` or a subdomain of it,
|
|
107
|
+
and list its exact origin in `passkey.rp.origins`.
|
|
108
|
+
- **Android:** publish `https://<rp.id>/.well-known/assetlinks.json` with
|
|
109
|
+
`delegate_permission/common.get_login_creds` for the app, and add
|
|
110
|
+
`android:apk-key-hash:<base64url SHA-256 of the signing certificate>` to
|
|
111
|
+
`passkey.rp.origins`. With Play App Signing, that is Google's app signing key.
|
|
112
|
+
- **iOS:** publish `https://<rp.id>/.well-known/apple-app-site-association` with
|
|
113
|
+
the app under `webcredentials.apps`, and add `webcredentials:<rp.id>` to the
|
|
114
|
+
app's Associated Domains entitlement.
|
|
115
|
+
- **Database:** tables for passkey credentials and challenges (reference Prisma
|
|
116
|
+
schema in the README), and, if your verification repository stores the type
|
|
117
|
+
in a database enum, the new value `step-up`.
|
|
118
|
+
|
|
119
|
+
### Added
|
|
120
|
+
|
|
121
|
+
- **Passkeys.** `PasskeyService` and `IPasskeyRepository`, enabled by
|
|
122
|
+
`passkeyRepositoryInstance` and `passkey.rp`:
|
|
123
|
+
```
|
|
124
|
+
registrationOptions(userId, { stepUpToken }) -> { challengeId, options }
|
|
125
|
+
verifyRegistration(userId, { challengeId, responseJson, name? })
|
|
126
|
+
authenticationOptions() -> { challengeId, options } // no username
|
|
127
|
+
verifyAuthentication({ challengeId, responseJson }) -> session
|
|
128
|
+
listPasskeys / renamePasskey / removePasskey
|
|
129
|
+
```
|
|
130
|
+
Discoverable credentials (usernameless sign-in, browser autofill),
|
|
131
|
+
`userVerification` configurable and `'required'` by default, EdDSA / ES256 /
|
|
132
|
+
RS256. Sessions come from `issueAuthSession`, so account status, cookie auth,
|
|
133
|
+
realm claims and `SESSION_ISSUED` apply unchanged. Verification is by
|
|
134
|
+
`@simplewebauthn/server`, loaded only when passkeys are configured and kept
|
|
135
|
+
out of the published type declarations.
|
|
136
|
+
- **What the package enforces on top of the library:** single-use challenges
|
|
137
|
+
bound to purpose, user and realm and consumed before anything is checked; the
|
|
138
|
+
realm on the stored credential; the `userHandle` (the library does not check
|
|
139
|
+
it); the signature counter, with a compare-and-set and a
|
|
140
|
+
`PASSKEY_COUNTER_REGRESSION` event; global uniqueness of credential ids
|
|
141
|
+
(`PasskeyCredentialExistsError`); refusing to remove an account's last way in.
|
|
142
|
+
- **Step-up re-authentication.** `StepUpService` and `StepUpTokenService`: a
|
|
143
|
+
signed-in user re-authenticates with a password, a passkey or an emailed
|
|
144
|
+
6-digit code and receives a five-minute token bound to user and realm.
|
|
145
|
+
**Adding a passkey requires one**: a passkey survives a password change and
|
|
146
|
+
logout-all, so a stolen session must not be able to add one. Wrong passwords
|
|
147
|
+
count toward brute-force lockout. The token is signed with an HKDF-derived
|
|
148
|
+
key, so it never passes as an access token.
|
|
149
|
+
- `BaseAuthResolver` methods: `performGetStepUpMethods`,
|
|
150
|
+
`performRequestStepUpEmailCode`, `performStepUpPasskeyOptions`,
|
|
151
|
+
`performReauthenticate`, `performPasskeyRegistrationOptions`,
|
|
152
|
+
`performVerifyPasskeyRegistration`, `performPasskeyAuthenticationOptions`,
|
|
153
|
+
`performVerifyPasskeyAuthentication`, `performListPasskeys`,
|
|
154
|
+
`performRenamePasskey`, `performRemovePasskey`. WebAuthn JSON crosses GraphQL
|
|
155
|
+
as a `String`.
|
|
156
|
+
- Options `passkey` (`rp`, `userVerification`, timeouts, `rateLimit`,
|
|
157
|
+
`userDisplayName`) and `stepUp.ttlSeconds`, validated at boot.
|
|
158
|
+
- Lifecycle hooks `onPasskeyRegistered` and `onPasskeyRemoved`, not awaited.
|
|
159
|
+
Wire the first to a notification email.
|
|
160
|
+
- Security events `PASSKEY_REGISTERED`, `PASSKEY_REGISTRATION_FAILURE`,
|
|
161
|
+
`PASSKEY_LOGIN_SUCCESS`, `PASSKEY_LOGIN_FAILURE`, `PASSKEY_COUNTER_REGRESSION`,
|
|
162
|
+
`PASSKEY_RENAMED`, `PASSKEY_REMOVED`, `STEP_UP_SUCCESS`, `STEP_UP_FAILURE`.
|
|
163
|
+
- Exceptions `StepUpRequiredException` (`STEP_UP_REQUIRED`),
|
|
164
|
+
`StepUpUnavailableException` (`STEP_UP_UNAVAILABLE`), `AuthRateLimitException`
|
|
165
|
+
(`RATE_LIMIT_EXCEEDED`), as `GraphQLError`s so the code reaches clients.
|
|
166
|
+
- `IEmailService.sendStepUpCodeEmail` and
|
|
167
|
+
`IEmailTemplateRenderer.renderStepUpCodeEmail`, both optional; implemented by
|
|
168
|
+
`ConfigurableEmailService` and `DefaultEmailTemplateRenderer`.
|
|
169
|
+
- `VerificationType.STEP_UP`, and `generateCodeForUserId` /
|
|
170
|
+
`validateCodeForUserId` accept `'step-up'`.
|
|
171
|
+
- `AuthService.requireActiveUser` and `AuthService.verifyPasswordForStepUp`.
|
|
172
|
+
- `InMemoryPasskeyRepository`, for tests and a single instance.
|
|
173
|
+
|
|
174
|
+
### Changed
|
|
175
|
+
|
|
176
|
+
- `RepositoryVerificationType` includes `VerificationType.STEP_UP`. An adapter
|
|
177
|
+
with an exhaustive `switch` over it needs the new case.
|
|
178
|
+
- `BaseAuthResolver` gains the protected properties `authPasskeys` and
|
|
179
|
+
`authStepUp`, injected by property so its constructor is unchanged. A
|
|
180
|
+
subclass that declares members with those names must rename them.
|
|
181
|
+
|
|
182
|
+
### Fixed
|
|
183
|
+
|
|
184
|
+
- The 0.14.0 notes and code comments said WebAuthn could be added as a second
|
|
185
|
+
`IBiometricVerifier` "without reshaping the port". It could not: a passkey
|
|
186
|
+
needs authenticator data and a user handle, a counter write, a registration
|
|
187
|
+
ceremony and challenges issued before any credential is known. Passkeys have
|
|
188
|
+
their own port, and the comments now say so.
|
|
189
|
+
|
|
30
190
|
## [0.15.1] - 2026-10-01
|
|
31
191
|
|
|
32
192
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Production-grade authentication module for NestJS GraphQL + REST APIs. JWT, cook
|
|
|
19
19
|
- [Phone \& SMS Verification](#phone--sms-verification)
|
|
20
20
|
- [OAuth](#oauth)
|
|
21
21
|
- [Biometric Authentication](#biometric-authentication)
|
|
22
|
+
- [Passkeys](#passkeys)
|
|
23
|
+
- [Step-Up Re-authentication](#step-up-re-authentication)
|
|
22
24
|
- [Brute Force Protection](#brute-force-protection)
|
|
23
25
|
- [Password Reset](#password-reset)
|
|
24
26
|
- [Password Policy](#password-policy)
|
|
@@ -38,6 +40,8 @@ Production-grade authentication module for NestJS GraphQL + REST APIs. JWT, cook
|
|
|
38
40
|
- [Composable Email](#composable-email-email)
|
|
39
41
|
- [Verification Options](#verification-options-verification)
|
|
40
42
|
- [Biometric Options (`biometric`)](#biometric-options-biometric)
|
|
43
|
+
- [Passkey Options (`passkey`)](#passkey-options-passkey)
|
|
44
|
+
- [Step-Up Options (`stepUp`)](#step-up-options-stepup)
|
|
41
45
|
- [SendGrid Options (`sendgrid`)](#sendgrid-options-sendgrid)
|
|
42
46
|
- [Twilio Options (`twilio`)](#twilio-options-twilio)
|
|
43
47
|
- [Optional Instance Options](#optional-instance-options)
|
|
@@ -79,6 +83,8 @@ npm install @nestjs/common @nestjs/core @nestjs/graphql @nestjs/jwt @nestjs/pass
|
|
|
79
83
|
|
|
80
84
|
`resend` is an optional peer dependency -- install it only if using the Resend email sender.
|
|
81
85
|
|
|
86
|
+
`@simplewebauthn/server` (^14, Node.js 20+) is an optional peer dependency -- install it only if using [passkeys](#passkeys). Boot fails with the install command if passkeys are configured without it.
|
|
87
|
+
|
|
82
88
|
Supports NestJS 10 and NestJS 11. CI runs the unit and end-to-end suites on both: NestJS 10 with `@nestjs/graphql` 12, Express 4 and Apollo Server 4, and NestJS 11 with `@nestjs/graphql` 13, Express 5 and Apollo Server 5.
|
|
83
89
|
|
|
84
90
|
## Quick Start
|
|
@@ -266,6 +272,22 @@ When cookie auth is enabled, pass the GraphQL `context` so tokens are set as Htt
|
|
|
266
272
|
| `performResetPassword(input, context?)` | Reset password |
|
|
267
273
|
| `performChangePassword(userId, current, new)` | Change password |
|
|
268
274
|
|
|
275
|
+
**Passkeys and step-up** (see [Passkeys](#passkeys)):
|
|
276
|
+
|
|
277
|
+
| Method | Purpose |
|
|
278
|
+
|--------|---------|
|
|
279
|
+
| `performGetStepUpMethods(user, context?)` | Which re-authentication methods this account has |
|
|
280
|
+
| `performRequestStepUpEmailCode(user, context?)` | Email a 6-digit re-authentication code |
|
|
281
|
+
| `performStepUpPasskeyOptions(user, context?)` | Options for re-authenticating with a passkey |
|
|
282
|
+
| `performReauthenticate(user, input, context?)` | Prove identity again; returns a step-up token |
|
|
283
|
+
| `performPasskeyRegistrationOptions(user, input, context?)` | Options for adding a passkey (needs a step-up token) |
|
|
284
|
+
| `performVerifyPasskeyRegistration(user, input, context?)` | Store the new passkey |
|
|
285
|
+
| `performPasskeyAuthenticationOptions(context?)` | Options for signing in (public) |
|
|
286
|
+
| `performVerifyPasskeyAuthentication(input, context?)` | Sign in; same response as `performLogin` |
|
|
287
|
+
| `performListPasskeys(user, context?)` | The user's passkeys |
|
|
288
|
+
| `performRenamePasskey(user, input, context?)` | Rename one |
|
|
289
|
+
| `performRemovePasskey(user, input, context?)` | Remove one (refused for the last way in) |
|
|
290
|
+
|
|
269
291
|
**Other:**
|
|
270
292
|
|
|
271
293
|
| Method | Purpose |
|
|
@@ -929,7 +951,7 @@ Treat this as *possession of a device-bound key that the client promises to
|
|
|
929
951
|
gate*. It is a good second factor and a good convenience login. Before making it
|
|
930
952
|
the sole factor for something sensitive, be clear that you are trusting the app,
|
|
931
953
|
not the biometric. WebAuthn's `userVerification` flag is what actually attests to
|
|
932
|
-
user verification, and is not this.
|
|
954
|
+
user verification, and is not this: for that, use [passkeys](#passkeys).
|
|
933
955
|
|
|
934
956
|
#### The flow
|
|
935
957
|
|
|
@@ -1012,6 +1034,340 @@ same answer.
|
|
|
1012
1034
|
biometric services are not registered at all, rather than silently accepting
|
|
1013
1035
|
enrolments that can never authenticate.
|
|
1014
1036
|
|
|
1037
|
+
### Passkeys
|
|
1038
|
+
|
|
1039
|
+
WebAuthn passkeys (since 0.16.0): registration for a signed-in user, then
|
|
1040
|
+
sign-in with no username at all. Enabled by `passkeyRepositoryInstance` plus
|
|
1041
|
+
`passkey.rp`, and needs the optional peer `@simplewebauthn/server` (^14,
|
|
1042
|
+
Node.js 20+).
|
|
1043
|
+
|
|
1044
|
+
```typescript
|
|
1045
|
+
passkeyRepositoryInstance: myPasskeyRepo, // enables the capability
|
|
1046
|
+
passkey: {
|
|
1047
|
+
rp: {
|
|
1048
|
+
id: 'example.com', // the domain passkeys are bound to
|
|
1049
|
+
name: 'Example',
|
|
1050
|
+
origins: [
|
|
1051
|
+
'https://app.example.com',
|
|
1052
|
+
'android:apk-key-hash:<base64url SHA-256 of your signing certificate>',
|
|
1053
|
+
],
|
|
1054
|
+
},
|
|
1055
|
+
// userVerification: 'required', // default; see below
|
|
1056
|
+
},
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
#### What a passkey sign-in proves
|
|
1060
|
+
|
|
1061
|
+
With `userVerification: 'required'` (the default): the user unlocked an
|
|
1062
|
+
authenticator **bound to this domain** that holds the registered key. Unlike
|
|
1063
|
+
the [biometric device key](#biometric-authentication), the authenticator itself
|
|
1064
|
+
attests that it verified the user, and a lookalike domain cannot use the
|
|
1065
|
+
credential. It does not prove *which* person unlocked the device, and a synced
|
|
1066
|
+
passkey is only as safe as the iCloud or Google account it syncs through.
|
|
1067
|
+
|
|
1068
|
+
**`'required'` adds no prompt for users.** On phones and laptops the Face ID,
|
|
1069
|
+
fingerprint or device PIN prompt *is* the passkey prompt, once per sign-in;
|
|
1070
|
+
sessions then continue through refresh tokens as usual. What `'required'`
|
|
1071
|
+
changes is that the server refuses an authenticator reporting that it did not
|
|
1072
|
+
verify the user, which in practice means a presence-only hardware key.
|
|
1073
|
+
`'preferred'` and `'discouraged'` accept those, and give up that guarantee.
|
|
1074
|
+
|
|
1075
|
+
#### The flow
|
|
1076
|
+
|
|
1077
|
+
```
|
|
1078
|
+
Add a passkey (signed in)
|
|
1079
|
+
performReauthenticate({ password }) -> { stepUpToken } (see Step-Up)
|
|
1080
|
+
performPasskeyRegistrationOptions({ stepUpToken }) -> { challengeId, optionsJson }
|
|
1081
|
+
client: navigator.credentials.create(...)
|
|
1082
|
+
performVerifyPasskeyRegistration({ challengeId, responseJson, name? })
|
|
1083
|
+
|
|
1084
|
+
Sign in (signed out)
|
|
1085
|
+
performPasskeyAuthenticationOptions() -> { challengeId, optionsJson }
|
|
1086
|
+
client: navigator.credentials.get(...)
|
|
1087
|
+
performVerifyPasskeyAuthentication({ challengeId, responseJson }) -> AuthResponse
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
WebAuthn JSON crosses GraphQL as a `String`: send `optionsJson` to the platform
|
|
1091
|
+
API unchanged, and send back `JSON.stringify` of what it returns. In a browser:
|
|
1092
|
+
|
|
1093
|
+
```typescript
|
|
1094
|
+
const { challengeId, optionsJson } = await passkeyAuthenticationOptions();
|
|
1095
|
+
const credential = await navigator.credentials.get({
|
|
1096
|
+
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(JSON.parse(optionsJson)),
|
|
1097
|
+
});
|
|
1098
|
+
await passkeyLogin({ challengeId, responseJson: JSON.stringify(credential.toJSON()) });
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
(`@simplewebauthn/browser`'s `startRegistration({ optionsJSON })` /
|
|
1102
|
+
`startAuthentication({ optionsJSON })` do the same on browsers without the
|
|
1103
|
+
`parse…FromJSON` helpers.) The sign-in options also serve browser autofill:
|
|
1104
|
+
call `navigator.credentials.get` with `mediation: 'conditional'`, and fetch
|
|
1105
|
+
fresh options when they expire (120 s by default). On Flutter, use a plugin that
|
|
1106
|
+
exchanges WebAuthn JSON with Android Credential Manager and iOS
|
|
1107
|
+
`ASAuthorization`, and pass the same two strings through.
|
|
1108
|
+
|
|
1109
|
+
A consumer resolver, in the same shape as every other operation:
|
|
1110
|
+
|
|
1111
|
+
```typescript
|
|
1112
|
+
@ObjectType() class PasskeyOptions { @Field() challengeId!: string; @Field() optionsJson!: string; }
|
|
1113
|
+
@ObjectType() class Passkey {
|
|
1114
|
+
@Field() credentialId!: string; @Field() name!: string;
|
|
1115
|
+
@Field() deviceType!: string; @Field() backedUp!: boolean;
|
|
1116
|
+
@Field() createdAt!: Date; @Field({ nullable: true }) lastUsedAt?: Date;
|
|
1117
|
+
}
|
|
1118
|
+
@ObjectType() class StepUpToken { @Field() stepUpToken!: string; @Field() expiresAt!: Date; }
|
|
1119
|
+
@InputType() class ReauthenticateInput {
|
|
1120
|
+
@Field({ nullable: true }) password?: string;
|
|
1121
|
+
@Field({ nullable: true }) emailCode?: string;
|
|
1122
|
+
@Field({ nullable: true }) passkeyChallengeId?: string;
|
|
1123
|
+
@Field({ nullable: true }) @MaxLength(16384) passkeyResponseJson?: string;
|
|
1124
|
+
}
|
|
1125
|
+
@InputType() class PasskeyRegistrationOptionsInput { @Field() stepUpToken!: string; }
|
|
1126
|
+
@InputType() class VerifyPasskeyInput {
|
|
1127
|
+
@Field() challengeId!: string;
|
|
1128
|
+
@Field() @MaxLength(16384) responseJson!: string;
|
|
1129
|
+
@Field({ nullable: true }) @MaxLength(64) name?: string;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
@Resolver()
|
|
1133
|
+
export class AppAuthResolver extends BaseAuthResolver<User> {
|
|
1134
|
+
@Mutation(() => StepUpToken) @UseGuards(JwtAuthGuard)
|
|
1135
|
+
reauthenticate(@Args('input') input: ReauthenticateInput, @CurrentUser() user: User, @Context() ctx: any) {
|
|
1136
|
+
return this.performReauthenticate(user, input, ctx);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
@Mutation(() => PasskeyOptions) @UseGuards(JwtAuthGuard)
|
|
1140
|
+
passkeyRegistrationOptions(@Args('input') input: PasskeyRegistrationOptionsInput, @CurrentUser() user: User, @Context() ctx: any) {
|
|
1141
|
+
return this.performPasskeyRegistrationOptions(user, input, ctx);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
@Mutation(() => Passkey) @UseGuards(JwtAuthGuard)
|
|
1145
|
+
verifyPasskeyRegistration(@Args('input') input: VerifyPasskeyInput, @CurrentUser() user: User, @Context() ctx: any) {
|
|
1146
|
+
return this.performVerifyPasskeyRegistration(user, input, ctx);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
@Mutation(() => PasskeyOptions)
|
|
1150
|
+
passkeyAuthenticationOptions(@Context() ctx: any) {
|
|
1151
|
+
return this.performPasskeyAuthenticationOptions(ctx);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
@Mutation(() => AuthResponse)
|
|
1155
|
+
passkeyLogin(@Args('input') input: VerifyPasskeyInput, @Context() ctx: any) {
|
|
1156
|
+
return this.performVerifyPasskeyAuthentication(input, ctx) as Promise<AuthResponse>;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
`PasskeyService` (exported) has the same operations if you need them outside a
|
|
1162
|
+
resolver.
|
|
1163
|
+
|
|
1164
|
+
#### What the package guarantees
|
|
1165
|
+
|
|
1166
|
+
| | |
|
|
1167
|
+
|---|---|
|
|
1168
|
+
| Phishing | Origin and rpId are verified against `passkey.rp`; an assertion made for another site fails |
|
|
1169
|
+
| Replay | Every challenge is single use, consumed atomically **before** anything is checked, and burned by a failed attempt. It carries its purpose, user and realm, so a sign-in challenge cannot be spent on registration or step-up |
|
|
1170
|
+
| Adding a passkey | Needs a [step-up token](#step-up-re-authentication). A passkey survives a password change and logout-all, so a stolen session must not be able to add one |
|
|
1171
|
+
| Realms | A passkey signs in only to the realm it was registered in, checked on the stored record whatever the repository does |
|
|
1172
|
+
| User handle | Must match the stored one on sign-in (the library does not check it) |
|
|
1173
|
+
| Cloned authenticators | A counting authenticator's counter must increase, enforced by the package with a compare-and-set; a regression is refused and logged as `PASSKEY_COUNTER_REGRESSION`. Synced passkeys always report 0, which is accepted |
|
|
1174
|
+
| Account status | Sessions come from `issueAuthSession`, so a suspended user is refused after a valid assertion, with `ACCOUNT_INACTIVE` rather than the generic failure |
|
|
1175
|
+
| Enumeration | Sign-in options take no input; every sign-in failure is the same `PASSKEY_AUTHENTICATION_FAILED` (401) |
|
|
1176
|
+
| Duplicates | A credential id already registered to anyone is refused with `PASSKEY_ALREADY_REGISTERED` (409), leaving the existing one untouched |
|
|
1177
|
+
| Last way in | Removing the only passkey of an account with no password and no social identity is refused with `CANNOT_REMOVE_LAST_AUTH_METHOD` (409) |
|
|
1178
|
+
| Owner notified | The package emails the owner when a passkey is added or removed (see [Notification emails](#notification-emails)) |
|
|
1179
|
+
| Rate limiting | Options are limited per user (registration, step-up) and per IP (sign-in); each verification consumes a challenge, so it is bounded by them |
|
|
1180
|
+
|
|
1181
|
+
#### Platform setup
|
|
1182
|
+
|
|
1183
|
+
None of this is visible to a compiler or a test suite; each item fails as an
|
|
1184
|
+
opaque error on the device.
|
|
1185
|
+
|
|
1186
|
+
| Platform | Needs |
|
|
1187
|
+
|---|---|
|
|
1188
|
+
| Web | The frontend on HTTPS, on a host equal to `rp.id` or under it, listed exactly in `rp.origins` (scheme, host, port; no path, no trailing slash) |
|
|
1189
|
+
| Android | `https://<rp.id>/.well-known/assetlinks.json` granting `delegate_permission/common.get_login_creds` to your package name and SHA-256 certificate fingerprint, **and** `android:apk-key-hash:<hash>` in `rp.origins` |
|
|
1190
|
+
| iOS | `https://<rp.id>/.well-known/apple-app-site-association` listing your app under `webcredentials.apps`, and `webcredentials:<rp.id>` in the app's Associated Domains entitlement. iOS reports the web origin `https://<rp.id>`, which must be in `rp.origins` |
|
|
1191
|
+
|
|
1192
|
+
The Android origin is the **base64url** SHA-256 of the signing certificate,
|
|
1193
|
+
not the colon-separated hex `keytool` prints. Convert it:
|
|
1194
|
+
|
|
1195
|
+
```bash
|
|
1196
|
+
echo 'AB:CD:...:EF' | tr -d ':' | xxd -r -p | base64 | tr '+/' '-_' | tr -d '='
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
With Play App Signing, use the fingerprint of Google's app signing key (Play
|
|
1200
|
+
Console → App integrity), not your upload key. Boot refuses an Android origin
|
|
1201
|
+
that is not 43 base64url characters.
|
|
1202
|
+
|
|
1203
|
+
**Several brands, one API:** every `rp` field can be a resolver taking the
|
|
1204
|
+
request's realm, e.g. `id: (realm) => brands[realm].domain`. Resolved values are
|
|
1205
|
+
checked per call, since boot cannot know every realm.
|
|
1206
|
+
|
|
1207
|
+
#### Implementing `IPasskeyRepository`
|
|
1208
|
+
|
|
1209
|
+
Two methods must be one conditional statement each, never a read followed by a
|
|
1210
|
+
write:
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
async consumeChallenge(challengeId: string) {
|
|
1214
|
+
const { count } = await prisma.passkeyChallenge.updateMany({
|
|
1215
|
+
where: { id: challengeId, used: false, expiresAt: { gt: new Date() } },
|
|
1216
|
+
data: { used: true, usedAt: new Date() },
|
|
1217
|
+
});
|
|
1218
|
+
if (count !== 1) return null;
|
|
1219
|
+
return this.loadChallenge(challengeId);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
async updateCounter({ credentialId, expectedCounter, newCounter, backedUp, lastUsedAt }) {
|
|
1223
|
+
const { count } = await prisma.passkeyCredential.updateMany({
|
|
1224
|
+
where: { credentialId, counter: expectedCounter },
|
|
1225
|
+
data: { counter: newCounter, backedUp, lastUsedAt },
|
|
1226
|
+
});
|
|
1227
|
+
return count === 1;
|
|
1228
|
+
}
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
And `createCredential` must throw `PasskeyCredentialExistsError` on a duplicate
|
|
1232
|
+
`credentialId` **for any user**, from a unique constraint (map Prisma's
|
|
1233
|
+
`P2002`). A reference schema:
|
|
1234
|
+
|
|
1235
|
+
```prisma
|
|
1236
|
+
model PasskeyCredential {
|
|
1237
|
+
credentialId String @id // base64url, unique across all users
|
|
1238
|
+
userId String
|
|
1239
|
+
realm String? // required when realms are enabled
|
|
1240
|
+
publicKey String // base64url COSE key
|
|
1241
|
+
counter BigInt @default(0) // uint32 on the wire; Int is too small
|
|
1242
|
+
userHandle String
|
|
1243
|
+
transports String[]
|
|
1244
|
+
deviceType String
|
|
1245
|
+
backedUp Boolean
|
|
1246
|
+
aaguid String?
|
|
1247
|
+
name String
|
|
1248
|
+
createdAt DateTime @default(now())
|
|
1249
|
+
lastUsedAt DateTime?
|
|
1250
|
+
isActive Boolean @default(true)
|
|
1251
|
+
metadata Json?
|
|
1252
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
1253
|
+
@@index([userId, realm])
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
model PasskeyChallenge {
|
|
1257
|
+
id String @id @default(uuid())
|
|
1258
|
+
challenge String
|
|
1259
|
+
purpose String // registration | authentication | step-up
|
|
1260
|
+
userId String? // not a foreign key
|
|
1261
|
+
realm String?
|
|
1262
|
+
userHandle String? // registration only
|
|
1263
|
+
expiresAt DateTime
|
|
1264
|
+
used Boolean @default(false)
|
|
1265
|
+
usedAt DateTime?
|
|
1266
|
+
@@index([expiresAt])
|
|
1267
|
+
}
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
`getCredential` returns deactivated credentials too; the package decides.
|
|
1271
|
+
|
|
1272
|
+
**Check your adapter against the contract** with the helper the package's own
|
|
1273
|
+
tests use, against a real database:
|
|
1274
|
+
|
|
1275
|
+
```typescript
|
|
1276
|
+
import { assertPasskeyRepositoryContract } from '@ambushsoftworks/nestjs-auth-graphql/testing';
|
|
1277
|
+
|
|
1278
|
+
it('meets the passkey repository contract', async () => {
|
|
1279
|
+
await assertPasskeyRepositoryContract(() => new PrismaPasskeyRepository(prisma));
|
|
1280
|
+
});
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
It resolves when the adapter is correct and otherwise rejects naming the rule
|
|
1284
|
+
broken: concurrent claims of one challenge, a counter column too small for a
|
|
1285
|
+
uint32, an unmapped duplicate, a dropped realm, hidden deactivated
|
|
1286
|
+
credentials, expiry left to a TTL. Framework-agnostic.
|
|
1287
|
+
`InMemoryPasskeyRepository` is exported for tests and single-instance
|
|
1288
|
+
development.
|
|
1289
|
+
|
|
1290
|
+
**Driving real ceremonies in your own tests:** `SoftwareAuthenticator`, from
|
|
1291
|
+
the same `/testing` entry, produces real WebAuthn responses (CBOR, COSE keys,
|
|
1292
|
+
signatures) from the options your API returns, with options to forge any field:
|
|
1293
|
+
|
|
1294
|
+
```typescript
|
|
1295
|
+
import { SoftwareAuthenticator } from '@ambushsoftworks/nestjs-auth-graphql/testing';
|
|
1296
|
+
|
|
1297
|
+
const device = new SoftwareAuthenticator();
|
|
1298
|
+
const response = device.create(JSON.parse(optionsJson), { origin: 'https://app.example.com' });
|
|
1299
|
+
// ... later, for sign-in:
|
|
1300
|
+
const assertion = device.get(JSON.parse(signInOptionsJson), { origin: 'https://app.example.com' });
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
#### Notification emails
|
|
1304
|
+
|
|
1305
|
+
The package emails the account owner when a passkey is **added** and when one
|
|
1306
|
+
is **removed**, through `IEmailService.sendPasskeyAddedEmail` /
|
|
1307
|
+
`sendPasskeyRemovedEmail`. The "added" email matters most: a passkey survives a
|
|
1308
|
+
password change and signing out everywhere, so it is how an owner learns of one
|
|
1309
|
+
they did not add.
|
|
1310
|
+
|
|
1311
|
+
| Your email setup | What happens |
|
|
1312
|
+
|---|---|
|
|
1313
|
+
| `email: {...}` (default renderer) | Sent, with the package's templates |
|
|
1314
|
+
| `ConfigurableEmailService` with your own renderer | Sent when your renderer implements `renderPasskeyAddedEmail` / `renderPasskeyRemovedEmail`; otherwise nothing is sent |
|
|
1315
|
+
| Your own `IEmailService` | Sent when it implements `sendPasskeyAddedEmail(email, passkeyName, addedAt)` / `sendPasskeyRemovedEmail(email, passkeyName, removedAt)`; otherwise nothing is sent |
|
|
1316
|
+
|
|
1317
|
+
`passkeyName` is chosen by the user: **escape it** in any HTML you render. The
|
|
1318
|
+
default templates do.
|
|
1319
|
+
|
|
1320
|
+
To send your own instead, or none, turn either off:
|
|
1321
|
+
|
|
1322
|
+
```typescript
|
|
1323
|
+
passkey: {
|
|
1324
|
+
rp: { /* ... */ },
|
|
1325
|
+
emailNotifications: { added: true, removed: false }, // both default to true
|
|
1326
|
+
},
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
**Don't also send them from `onPasskeyRegistered` / `onPasskeyRemoved`**, or
|
|
1330
|
+
owners get two. The hooks still fire, for anything else you want to do.
|
|
1331
|
+
|
|
1332
|
+
### Step-Up Re-authentication
|
|
1333
|
+
|
|
1334
|
+
A signed-in user proves again who they are and receives a short-lived **step-up
|
|
1335
|
+
token** (5 minutes by default) for a protected action. Adding a passkey requires
|
|
1336
|
+
one.
|
|
1337
|
+
|
|
1338
|
+
| Proof | Available when |
|
|
1339
|
+
|---|---|
|
|
1340
|
+
| `{ password }` | The account has a password. **Failures count toward [lockout](#brute-force-protection)**, so a stolen session cannot guess it freely |
|
|
1341
|
+
| `{ passkeyChallengeId, passkeyResponseJson }` | Passkeys are configured and the user has one (options from `performStepUpPasskeyOptions`) |
|
|
1342
|
+
| `{ emailCode }` | `verificationRepositoryInstance` is set and the email service implements `sendStepUpCodeEmail` (`ConfigurableEmailService` does). Always a 6-digit code, never a link; one email a minute |
|
|
1343
|
+
|
|
1344
|
+
`performGetStepUpMethods` says which apply, so the client shows only working
|
|
1345
|
+
buttons.
|
|
1346
|
+
|
|
1347
|
+
| Code | Status | When |
|
|
1348
|
+
|---|---|---|
|
|
1349
|
+
| `STEP_UP_FAILED` | 403 | The proof was wrong: password, code or passkey |
|
|
1350
|
+
| `STEP_UP_REQUIRED` | 403 | An action needs a token and got none, or an invalid one |
|
|
1351
|
+
| `STEP_UP_UNAVAILABLE` | 403 | The account has no such method |
|
|
1352
|
+
|
|
1353
|
+
**A failed proof is deliberately not `UNAUTHENTICATED`.** Clients read that as
|
|
1354
|
+
an expired session and refresh-and-retry: the wrong password would count twice
|
|
1355
|
+
toward lockout, and a passkey proof's retry always fails because its challenge
|
|
1356
|
+
is already spent. Don't map `STEP_UP_FAILED` to a token refresh.
|
|
1357
|
+
|
|
1358
|
+
The token is signed with a key derived from `jwtSecret` (HKDF), never with
|
|
1359
|
+
`jwtSecret` itself, so it can never pass as an access token. It is bound to the
|
|
1360
|
+
user and realm.
|
|
1361
|
+
|
|
1362
|
+
**A token is reusable until it expires, not single-use.** One re-authentication
|
|
1363
|
+
can cover adding a passkey and then another protected action within the five
|
|
1364
|
+
minutes. That is deliberate: the token is useless without the same user's
|
|
1365
|
+
session in the same realm, and single use would need storage for little gain
|
|
1366
|
+
over a short expiry. Shorten `stepUp.ttlSeconds` if you want a tighter window.
|
|
1367
|
+
|
|
1368
|
+
**Email codes use a new verification type, `step-up`.** If your verification
|
|
1369
|
+
repository stores the type in a database enum, add the value.
|
|
1370
|
+
|
|
1015
1371
|
### Brute Force Protection
|
|
1016
1372
|
|
|
1017
1373
|
Provide a `bruteForceRepositoryInstance` and the module tracks failed login attempts and temporarily locks accounts. Without one, `NoOpBruteForceRepository` is used and no account is ever locked; there is no flag to set. The lockout policy (attempt thresholds, lockout duration) is determined by your `IBruteForceRepository` implementation.
|
|
@@ -1228,10 +1584,12 @@ lifecycleHooksInstance: {
|
|
|
1228
1584
|
onAccountLocked(userId, email, ip, lockSeconds, realm) {
|
|
1229
1585
|
/* send account-locked notification email, alert security tooling */
|
|
1230
1586
|
},
|
|
1587
|
+
async onPasskeyRegistered(user, passkey, realm) { /* analytics; the package emails the owner */ },
|
|
1588
|
+
async onPasskeyRemoved(user, passkey, realm) { /* likewise */ },
|
|
1231
1589
|
}
|
|
1232
1590
|
```
|
|
1233
1591
|
|
|
1234
|
-
All hooks are optional. Async hooks are awaited but failures are logged and do not block the auth operation. `onAuthFailure` and `onAccountLocked` are synchronous (fire-and-forget) — both are security signals and the underlying state (lock recorded, attempt logged) is already persisted by the time the hook fires, so hook failures must not roll it back.
|
|
1592
|
+
All hooks are optional. Async hooks are awaited but failures are logged and do not block the auth operation; the passkey hooks are not awaited. The package sends the passkey notification emails itself (see [Notification emails](#notification-emails)); don't send them again from these hooks. `onAuthFailure` and `onAccountLocked` are synchronous (fire-and-forget) — both are security signals and the underlying state (lock recorded, attempt logged) is already persisted by the time the hook fires, so hook failures must not roll it back.
|
|
1235
1593
|
|
|
1236
1594
|
`onAccountLocked` fires from `BruteForceProtectionService.recordFailedAttempt` on the transition from "not locked" → "locked". Subsequent failed attempts while the account remains locked do **not** re-fire (use `onAuthFailure` for per-attempt signal). Wire it to send the account-locked email yourself — the package no longer ships a `sendAccountLockedEmail` method on `IEmailService`; notification surface area beyond verification, password-reset, and password-changed is consumer-owned via this hook.
|
|
1237
1595
|
|
|
@@ -1460,6 +1818,29 @@ without it is reported at boot, not refused.
|
|
|
1460
1818
|
| `challengeExpirySeconds` | `number` | `60` | Short on purpose: it bounds replay |
|
|
1461
1819
|
| `challengeRateLimit` | `{ maxAttempts, windowMs }` | `5 / 60s` | Per credential. **Requires `rateLimiterInstance`** — the same guardrail as `bruteForce.ipRateLimit`, because per-replica counters would make the limit silently looser. A per-IP limit at ten times this bound also applies when you pass `ipAddress` |
|
|
1462
1820
|
|
|
1821
|
+
### Passkey Options (`passkey`)
|
|
1822
|
+
|
|
1823
|
+
Read only when `passkeyRepositoryInstance` is provided; without it this block is
|
|
1824
|
+
reported at boot as ignored. With it, `rp` is required.
|
|
1825
|
+
|
|
1826
|
+
| Option | Type | Default | Description |
|
|
1827
|
+
|--------|------|---------|-------------|
|
|
1828
|
+
| `rp.id` | `string \| (realm?) => string` | — | The registrable domain passkeys are bound to. No scheme, port or path; not an IP |
|
|
1829
|
+
| `rp.name` | `string \| (realm?) => string` | — | Shown by some authenticators |
|
|
1830
|
+
| `rp.origins` | `string[] \| (realm?) => string[]` | — | Exact origins accepted, each `rp.id` or a subdomain of it, https except localhost; Android `android:apk-key-hash:` origins |
|
|
1831
|
+
| `userVerification` | `'required' \| 'preferred' \| 'discouraged'` | `'required'` | See [What a passkey sign-in proves](#what-a-passkey-sign-in-proves) |
|
|
1832
|
+
| `registrationTimeoutSeconds` | `number` | `300` | Registration ceremony lifetime |
|
|
1833
|
+
| `authenticationTimeoutSeconds` | `number` | `120` | Sign-in and step-up ceremony lifetime |
|
|
1834
|
+
| `rateLimit` | `{ maxAttempts, windowMs }` | `10 / 60s` | Options per user (registration, step-up) and per IP (sign-in). **Requires `rateLimiterInstance`** when set |
|
|
1835
|
+
| `userDisplayName` | `(user) => string` | the email | What authenticators show for the account |
|
|
1836
|
+
| `emailNotifications` | `{ added?: boolean, removed?: boolean }` | both `true` | Email the owner when a passkey is added / removed. See [Notification emails](#notification-emails) |
|
|
1837
|
+
|
|
1838
|
+
### Step-Up Options (`stepUp`)
|
|
1839
|
+
|
|
1840
|
+
| Option | Type | Default | Description |
|
|
1841
|
+
|--------|------|---------|-------------|
|
|
1842
|
+
| `ttlSeconds` | `number` | `300` | Step-up token lifetime, 1 to 3600 |
|
|
1843
|
+
|
|
1463
1844
|
### Optional Instance Options
|
|
1464
1845
|
|
|
1465
1846
|
| Option | Interface | Fallback |
|
|
@@ -1469,7 +1850,8 @@ without it is reported at boot, not refused.
|
|
|
1469
1850
|
| `lifecycleHooksInstance` | `IAuthLifecycleHooks` | `{}` (no-op) |
|
|
1470
1851
|
| `verificationRepositoryInstance` | `IVerificationRepository` | `NoOpVerificationRepository` |
|
|
1471
1852
|
| `bruteForceRepositoryInstance` | `IBruteForceRepository` | `NoOpBruteForceRepository` |
|
|
1472
|
-
| `biometricRepositoryInstance` | `IBiometricRepository` |
|
|
1853
|
+
| `biometricRepositoryInstance` | `IBiometricRepository` | none: biometrics disabled |
|
|
1854
|
+
| `passkeyRepositoryInstance` | `IPasskeyRepository` | none: passkeys disabled |
|
|
1473
1855
|
| `authLoggerInstance` | `IAuthLogger` | `ConsoleAuthLogger` |
|
|
1474
1856
|
| `tenantRepositoryInstance` | `ITenantRepository` | `null` |
|
|
1475
1857
|
| `tenantExtractorInstance` | `ITenantExtractor` | `null` |
|
package/dist/auth.module.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { IRefreshTokenRepository } from './interfaces/refresh-token-repository.i
|
|
|
9
9
|
import { IVerificationRepository } from './interfaces/verification-repository.interface';
|
|
10
10
|
import { IBruteForceRepository } from './interfaces/brute-force-repository.interface';
|
|
11
11
|
import { IBiometricRepository } from './interfaces/biometric-repository.interface';
|
|
12
|
+
import { IPasskeyRepository } from './interfaces/passkey-repository.interface';
|
|
13
|
+
import { PasskeyRelyingPartyConfig } from './utils/passkey-rp';
|
|
12
14
|
import { IAuthLogger } from './interfaces/auth-logger.interface';
|
|
13
15
|
import { ITenantRepository } from './interfaces/tenant-repository.interface';
|
|
14
16
|
import { ITenantExtractor } from './interfaces/tenant-extractor.interface';
|
|
@@ -32,6 +34,7 @@ export interface AuthModuleOptions {
|
|
|
32
34
|
verificationRepositoryInstance?: IVerificationRepository;
|
|
33
35
|
bruteForceRepositoryInstance?: IBruteForceRepository;
|
|
34
36
|
biometricRepositoryInstance?: IBiometricRepository;
|
|
37
|
+
passkeyRepositoryInstance?: IPasskeyRepository;
|
|
35
38
|
authLoggerInstance?: IAuthLogger;
|
|
36
39
|
refreshTokenSecret?: string;
|
|
37
40
|
verificationCodeSecret?: string;
|
|
@@ -130,6 +133,24 @@ export interface AuthModuleOptions {
|
|
|
130
133
|
windowMs: number;
|
|
131
134
|
};
|
|
132
135
|
};
|
|
136
|
+
passkey?: {
|
|
137
|
+
rp: PasskeyRelyingPartyConfig;
|
|
138
|
+
userVerification?: 'required' | 'preferred' | 'discouraged';
|
|
139
|
+
registrationTimeoutSeconds?: number;
|
|
140
|
+
authenticationTimeoutSeconds?: number;
|
|
141
|
+
rateLimit?: {
|
|
142
|
+
maxAttempts: number;
|
|
143
|
+
windowMs: number;
|
|
144
|
+
};
|
|
145
|
+
userDisplayName?: (user: IAuthUser) => string;
|
|
146
|
+
emailNotifications?: {
|
|
147
|
+
added?: boolean;
|
|
148
|
+
removed?: boolean;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
stepUp?: {
|
|
152
|
+
ttlSeconds?: number;
|
|
153
|
+
};
|
|
133
154
|
verification?: {
|
|
134
155
|
tokenLength?: number;
|
|
135
156
|
tokenExpiresInMinutes?: number;
|