@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +385 -3
  3. package/dist/auth.module.d.ts +21 -0
  4. package/dist/auth.module.d.ts.map +1 -1
  5. package/dist/auth.module.js +104 -0
  6. package/dist/auth.module.js.map +1 -1
  7. package/dist/constants.d.ts +2 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/constants.js +3 -1
  10. package/dist/constants.js.map +1 -1
  11. package/dist/dto/passkey.dto.d.ts +43 -0
  12. package/dist/dto/passkey.dto.d.ts.map +1 -0
  13. package/dist/dto/passkey.dto.js +5 -0
  14. package/dist/dto/passkey.dto.js.map +1 -0
  15. package/dist/enums/verification-type.enum.d.ts +3 -2
  16. package/dist/enums/verification-type.enum.d.ts.map +1 -1
  17. package/dist/enums/verification-type.enum.js +1 -0
  18. package/dist/enums/verification-type.enum.js.map +1 -1
  19. package/dist/exceptions/passkey.exceptions.d.ts +11 -0
  20. package/dist/exceptions/passkey.exceptions.d.ts.map +1 -0
  21. package/dist/exceptions/passkey.exceptions.js +29 -0
  22. package/dist/exceptions/passkey.exceptions.js.map +1 -0
  23. package/dist/exceptions/step-up.exceptions.d.ts +14 -0
  24. package/dist/exceptions/step-up.exceptions.d.ts.map +1 -0
  25. package/dist/exceptions/step-up.exceptions.js +41 -0
  26. package/dist/exceptions/step-up.exceptions.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +9 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/interfaces/auth-lifecycle-hooks.interface.d.ts +2 -0
  32. package/dist/interfaces/auth-lifecycle-hooks.interface.d.ts.map +1 -1
  33. package/dist/interfaces/auth-logger.interface.d.ts +10 -1
  34. package/dist/interfaces/auth-logger.interface.d.ts.map +1 -1
  35. package/dist/interfaces/auth-logger.interface.js +9 -0
  36. package/dist/interfaces/auth-logger.interface.js.map +1 -1
  37. package/dist/interfaces/biometric-verifier.interface.d.ts.map +1 -1
  38. package/dist/interfaces/email-service.interface.d.ts +3 -0
  39. package/dist/interfaces/email-service.interface.d.ts.map +1 -1
  40. package/dist/interfaces/email-template-renderer.interface.d.ts +15 -0
  41. package/dist/interfaces/email-template-renderer.interface.d.ts.map +1 -1
  42. package/dist/interfaces/index.d.ts +1 -0
  43. package/dist/interfaces/index.d.ts.map +1 -1
  44. package/dist/interfaces/index.js +1 -0
  45. package/dist/interfaces/index.js.map +1 -1
  46. package/dist/interfaces/passkey-repository.interface.d.ts +48 -0
  47. package/dist/interfaces/passkey-repository.interface.d.ts.map +1 -0
  48. package/dist/interfaces/passkey-repository.interface.js +13 -0
  49. package/dist/interfaces/passkey-repository.interface.js.map +1 -0
  50. package/dist/passkey/passkey-json.types.d.ts +44 -0
  51. package/dist/passkey/passkey-json.types.d.ts.map +1 -0
  52. package/dist/passkey/passkey-json.types.js +3 -0
  53. package/dist/passkey/passkey-json.types.js.map +1 -0
  54. package/dist/passkey/webauthn-adapter.d.ts +104 -0
  55. package/dist/passkey/webauthn-adapter.d.ts.map +1 -0
  56. package/dist/passkey/webauthn-adapter.js +207 -0
  57. package/dist/passkey/webauthn-adapter.js.map +1 -0
  58. package/dist/repositories/in-memory-passkey.repository.d.ts +21 -0
  59. package/dist/repositories/in-memory-passkey.repository.d.ts.map +1 -0
  60. package/dist/repositories/in-memory-passkey.repository.js +72 -0
  61. package/dist/repositories/in-memory-passkey.repository.js.map +1 -0
  62. package/dist/resolvers/base-auth.resolver.d.ts +20 -0
  63. package/dist/resolvers/base-auth.resolver.d.ts.map +1 -1
  64. package/dist/resolvers/base-auth.resolver.js +104 -0
  65. package/dist/resolvers/base-auth.resolver.js.map +1 -1
  66. package/dist/services/auth.service.d.ts +5 -0
  67. package/dist/services/auth.service.d.ts.map +1 -1
  68. package/dist/services/auth.service.js +24 -0
  69. package/dist/services/auth.service.js.map +1 -1
  70. package/dist/services/biometric-challenge.service.d.ts.map +1 -1
  71. package/dist/services/biometric-challenge.service.js.map +1 -1
  72. package/dist/services/configurable-email.service.d.ts +3 -0
  73. package/dist/services/configurable-email.service.d.ts.map +1 -1
  74. package/dist/services/configurable-email.service.js +54 -0
  75. package/dist/services/configurable-email.service.js.map +1 -1
  76. package/dist/services/default-email-template-renderer.d.ts +16 -0
  77. package/dist/services/default-email-template-renderer.d.ts.map +1 -1
  78. package/dist/services/default-email-template-renderer.js +84 -0
  79. package/dist/services/default-email-template-renderer.js.map +1 -1
  80. package/dist/services/passkey.service.d.ts +104 -0
  81. package/dist/services/passkey.service.d.ts.map +1 -0
  82. package/dist/services/passkey.service.js +519 -0
  83. package/dist/services/passkey.service.js.map +1 -0
  84. package/dist/services/step-up-token.service.d.ts +15 -0
  85. package/dist/services/step-up-token.service.d.ts.map +1 -0
  86. package/dist/services/step-up-token.service.js +71 -0
  87. package/dist/services/step-up-token.service.js.map +1 -0
  88. package/dist/services/step-up.service.d.ts +50 -0
  89. package/dist/services/step-up.service.d.ts.map +1 -0
  90. package/dist/services/step-up.service.js +142 -0
  91. package/dist/services/step-up.service.js.map +1 -0
  92. package/dist/services/verification.service.d.ts +2 -2
  93. package/dist/services/verification.service.d.ts.map +1 -1
  94. package/dist/services/verification.service.js +5 -4
  95. package/dist/services/verification.service.js.map +1 -1
  96. package/dist/test-utils/software-authenticator.d.ts +37 -0
  97. package/dist/test-utils/software-authenticator.d.ts.map +1 -0
  98. package/dist/test-utils/software-authenticator.js +212 -0
  99. package/dist/test-utils/software-authenticator.js.map +1 -0
  100. package/dist/testing/index.d.ts +3 -0
  101. package/dist/testing/index.d.ts.map +1 -0
  102. package/dist/testing/index.js +9 -0
  103. package/dist/testing/index.js.map +1 -0
  104. package/dist/testing/passkey-repository.contract.d.ts +8 -0
  105. package/dist/testing/passkey-repository.contract.d.ts.map +1 -0
  106. package/dist/testing/passkey-repository.contract.js +248 -0
  107. package/dist/testing/passkey-repository.contract.js.map +1 -0
  108. package/dist/utils/passkey-rp.d.ts +13 -0
  109. package/dist/utils/passkey-rp.d.ts.map +1 -0
  110. package/dist/utils/passkey-rp.js +89 -0
  111. package/dist/utils/passkey-rp.js.map +1 -0
  112. package/package.json +7 -1
  113. 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` | `NoOpBiometricRepository` |
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` |
@@ -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;