@productcraft/auth 0.5.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,8 +90,11 @@ await consumer.auth.signup({
90
90
 
91
91
  await consumer.auth.refresh({ refresh_token });
92
92
  await consumer.auth.logout({ refresh_token });
93
- await consumer.auth.requestReset({ email });
94
- await consumer.auth.resetPassword({ token, new_password: "..." });
93
+
94
+ // Forgot password. `sendPasswordResetEmail` mints the code AND emails it
95
+ // via Mail — this is what you want for a normal reset flow.
96
+ await consumer.auth.sendPasswordResetEmail({ email });
97
+ await consumer.auth.resetPassword({ code, new_password: "..." });
95
98
 
96
99
  // Sign in with Apple (native iOS flow). See "Federated sign-in" below.
97
100
  await consumer.auth.signinWithProvider({
@@ -116,6 +119,27 @@ await consumer.oauth.clientCredentials({ clientId, clientSecret, scope });
116
119
 
117
120
  > **Wire-shape note.** Auth's JSON wire is snake_case, and the SDK returns response objects as-is (e.g. `access_token`, `refresh_token`, `token_type`, `expires_in`). Request DTOs follow the same convention; some convenience fields (`identifier`, `password`, `nonce`) are unaffected, but anywhere the spec uses an underscore the SDK does too.
118
121
 
122
+ ### One-time codes: who sends the email?
123
+
124
+ Verification and password-reset each come in two flavours. **Picking the wrong one is silent** — you get a `2xx` and no email is ever sent — so choose deliberately:
125
+
126
+ | Method | Mints a code | Sends the email | Returns |
127
+ | --- | --- | --- | --- |
128
+ | `auth.requestVerification()` | ✅ | ❌ | `{ code, expires_at }` |
129
+ | `auth.sendVerificationEmail()` | ✅ | ✅ via Mail | `{ expires_at }` |
130
+ | `auth.requestReset()` | ✅ | ❌ | `{ code, expires_at }` |
131
+ | `auth.sendPasswordResetEmail()` | ✅ | ✅ via Mail | `{ expires_at }` |
132
+
133
+ **Use `send*` for a normal flow.** Mail delivers the code using your app's configured sender and template, and you never handle the plaintext code.
134
+
135
+ **Use `request*` only when you are delivering the code yourself** — your own SMTP, an SMS provider, or consuming it in-process (e.g. provisioning a test account). If you call `request*` and drop the returned `code` on the floor, your users get nothing and nothing is logged anywhere, because no send was ever attempted.
136
+
137
+ Each pair is gated by a *separate* PAK permission (`auth.user.verify.create` vs `auth.user.verify.send-email`, and likewise for `password-reset`), so a workspace can enable minting without enabling outbound mail.
138
+
139
+ The `send*` methods fail loudly with a typed `412` when Mail isn't ready — `ENVOI_NOT_ENABLED`, `ENVOI_SENDER_NOT_CONFIGURED`, `ENVOI_TEMPLATE_NOT_CONFIGURED`, `ENVOI_APP_NOT_BOUND_TO_WORKSPACE` — or `503 ENVOI_DISPATCH_FAILED`. If your endpoint returns a uniform response to prevent account enumeration, catch and **log** these rather than discarding them; a swallowed 412 is indistinguishable from the silent failure above.
140
+
141
+ Both `request*` and `send*` return `{}` when the contact doesn't match (or is already verified / unverified as appropriate). That's the anti-enumeration contract, not an error — there is nothing to branch on.
142
+
119
143
  ## Federated sign-in (Apple, Google)
120
144
 
121
145
  `consumer.auth.signinWithProvider` lets a BFF exchange a provider-issued identity token for an Auth `{ access_token, refresh_token }` pair. The endpoint creates the EndUser on first sign-in and returns the same account on subsequent ones — the SDK is a thin wrapper over Auth's `POST /{appSlug}/v1/auth/oauth/{provider}` which does the heavy lifting (Apple JWKS verification, issuer pinning, audience validation against the app's configured native client ids, server-side nonce replay protection, account creation / linking).
@@ -193,6 +217,56 @@ await consumer.auth.signinWithProvider({
193
217
 
194
218
  No SDK upgrade required — the per-provider TS enum widens automatically with the next spec refresh.
195
219
 
220
+ ## Passkeys (WebAuthn)
221
+
222
+ Passkeys are switched on per app in the console (relying-party id + the
223
+ site origin in `allowed_redirect_origins`). Once they are, each ceremony
224
+ is one call on `auth.consumer(appSlug).passkeys`. The helpers run
225
+ **in the browser** — they drive `navigator.credentials` — but importing
226
+ the package anywhere is safe; nothing touches a browser global until you
227
+ call them.
228
+
229
+ ```ts
230
+ import { Auth, isPasskeySupported, PasskeyError } from "@productcraft/auth";
231
+
232
+ if (!isPasskeySupported()) hidePasskeyButton(); // false in Node and in insecure contexts
233
+
234
+ // Enrol — signed-in user adds a passkey as an MFA factor
235
+ const me = new Auth({ auth: { type: "bearer", token: accessToken } }).consumer("acme");
236
+ const { factor, recovery_codes } = await me.passkeys.enroll({ label: "MacBook" });
237
+ // show recovery_codes once — they are not retrievable later
238
+
239
+ // Passwordless sign-in — no password, no email; discoverable credential
240
+ const anon = new Auth().consumer("acme");
241
+ const session = await anon.passkeys.signIn({ mediation: "conditional" }); // amr: ["webauthn"]
242
+
243
+ // MFA step of a password sign-in — when signin() answered { mfa_required, mfa_token, factors }
244
+ const session2 = await anon.passkeys.completeMfaChallenge({ mfa_token: signin.mfa_token });
245
+
246
+ // Step-up inside a live session
247
+ const { amr, mfa_at } = await me.passkeys.stepUp();
248
+ ```
249
+
250
+ Each helper does the options call → `navigator.credentials.create()` /
251
+ `.get()` → verify call, with every buffer base64url-encoded in both
252
+ directions and the `public_key` members renamed to the WebAuthn spec's
253
+ camelCase (the Auth wire is snake_case). Browser-side failures reject
254
+ with `PasskeyError` (`code`: `unsupported`, `cancelled`, `timeout`,
255
+ `invalid_state`, `security`, `no_credential`, `unknown`; `cause` holds
256
+ the DOM error); server-side failures stay `AuthHttpError`, so a 401 on
257
+ a forged or replayed assertion is distinguishable from the user
258
+ dismissing the prompt. Pass `signal: AbortSignal.timeout(ms)` to bound
259
+ the prompt yourself.
260
+
261
+ The encoding layer is exported for anyone who wants the pieces:
262
+ `toB64u`, `fromB64u`, `decodeOptions`, `encodeCredential`, plus
263
+ `createPasskeyCredential(public_key)` / `getPasskeyCredential(public_key)`
264
+ which wrap `navigator.credentials` on their own.
265
+
266
+ `signIn()` never sends an identifier: `/auth/passkey/options` takes none
267
+ by design (it would be an account-existence oracle), so sign-in relies on
268
+ a discoverable (resident) credential.
269
+
196
270
  ## JWT verification
197
271
 
198
272
  Every Auth app publishes a JWKS at `/{appSlug}/v1/.well-known/jwks.json`. The SDK gives you a verified-claims helper and a jose-compatible JWKS resolver.
@@ -255,7 +329,7 @@ For the per-app boundary, the JWKS is the cryptographic gate (only the app's key
255
329
 
256
330
  ### Passport integration
257
331
 
258
- Use the companion package [`@productcraft/auth-passport`](../auth-passport):
332
+ Use the companion package [`@productcraft/auth-passport`](https://github.com/clauderanelagh/productcraft-node/tree/main/packages/auth-passport):
259
333
 
260
334
  ```bash
261
335
  npm install @productcraft/auth-passport passport-jwt
@@ -319,4 +393,4 @@ When the spec changes, the nightly `spec-refresh` workflow re-runs codegen and o
319
393
 
320
394
  ## License
321
395
 
322
- [MIT](../../LICENSE).
396
+ [MIT](https://github.com/clauderanelagh/productcraft-node/blob/main/LICENSE).