@productcraft/auth 0.8.4 → 1.0.0-next-20261107024753

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
@@ -1,414 +1,259 @@
1
1
  # @productcraft/auth
2
2
 
3
- Typed Node.js SDK for the [ProductCraft Auth](https://productcraft.co) auth platform.
3
+ Node.js / TypeScript client for **ProductCraft Auth**: sign-in, sign-up, sessions, profiles, MFA, passkeys and tenants for the end users of the apps you build, plus the admin API for those apps (end users, roles, permissions, API keys, M2M credentials, config, webhooks) and offline token verification.
4
4
 
5
- ```bash
6
- npm install @productcraft/auth
7
- ```
8
-
9
- Server-side only. For React / Next.js apps, call this from your backend (BFF pattern) — the SDK ships an API key in headers.
10
-
11
- ## Quick start
12
-
13
- ```ts
14
- import { Auth } from "@productcraft/auth";
5
+ Every HTTP method, type, error class and mock is generated from the production OpenAPI spec. Upgrading from 0.x? See the [1.0 migration guide](https://github.com/clauderanelagh/productcraft-node/blob/main/docs/migrations/auth-1.0.0.md): every 0.6 and 0.8 method name still works as a deprecated alias.
15
6
 
16
- // Reads PRODUCTCRAFT_API_KEY (and PRODUCTCRAFT_AUTH_APP) from the environment
17
- const auth = new Auth();
18
- ```
19
-
20
- The SDK splits into three caller contexts.
21
-
22
- ### 1. Workspace-wide admin
23
-
24
- ```ts before
25
- // /v1/apps, /v1/stats/me
26
- const apps = await auth.apps.list();
27
-
28
- // Create requires display_name + workspace_id (not `name`).
29
- await auth.apps.create({
30
- display_name: "My App",
31
- slug: "my-app",
32
- workspace_id: "<workspace-uuid>",
33
- });
7
+ ## Install
34
8
 
35
- const stats = await auth.stats.get();
9
+ ```sh
10
+ npm install @productcraft/auth
36
11
  ```
37
12
 
38
- ### 2. App-scoped admin — `auth.app(appId)`
13
+ Node 20.3 or later (ESM and CommonJS), Bun, Deno and edge runtimes. Or install the umbrella package for every product: `npm install productcraft`.
39
14
 
40
- Pre-binds the appId path param so resource methods read like `app.endUsers.list()`.
15
+ ## Two scopes
41
16
 
42
- ```ts before
43
- const app = auth.app("<app-uuid>");
17
+ | Scope | Who calls it | Credential |
18
+ |---|---|---|
19
+ | `auth.consumer(slug)`: sign-in, sign-up, refresh, `me`, MFA, passkeys, tenants | Your app's backend or browser, for its end users | None for the public routes; the end user's access token for `me.*`; a workspace key only for the PAK-gated routes (pass it explicitly) |
20
+ | `auth.admin.app(appId)` (alias `auth.app(appId)`): end users, roles, keys, config, webhooks | Your backend or tooling | A workspace API key (`PRODUCTCRAFT_API_KEY`) or a platform session |
44
21
 
45
- // EndUsers — profile updates only carry { display_name?, email? }.
46
- // Status / role transitions are separate calls.
47
- const users = await app.endUsers.list({ limit: "20", cursor: "..." });
48
- await app.endUsers.update(userId, { display_name: "Alice Smith" });
49
- await app.endUsers.updateStatus(userId, { status: "active" });
50
- await app.endUsers.revokeAllSessions(userId);
22
+ `auth.consumer()` defaults its slug to `PRODUCTCRAFT_AUTH_APP` and never reads `PRODUCTCRAFT_API_KEY`: routes that act for an end user must not carry your workspace key by accident.
51
23
 
52
- // Roles — `CreateRoleDto` is { name, description? }. Permissions are
53
- // bound separately, after the role exists.
54
- await app.roles.create({ name: "admin", description: "Billing admin" });
55
- await app.roles.setPermissions("admin", { permissions: ["billing.read"] });
56
- await app.roles.assign({ userId, roleName: "admin" });
57
- await app.permissions.list();
24
+ ## Quick start
58
25
 
59
- // API keys — permissions[] is required even if empty.
60
- await app.apiKeys.create({ name: "ci", permissions: [] });
26
+ ```ts
27
+ import { Auth, AuthenticationError } from "@productcraft/auth";
61
28
 
62
- // M2M credentials
63
- const m2m = await app.credentials.create({ name: "backend-svc" });
29
+ const auth = new Auth();
30
+ const app = auth.consumer("my-app");
31
+
32
+ // Sign in an end user. `result` says whether MFA is still needed.
33
+ const session = await app.signin({ identifier: "ada@example.com", password: "correct horse battery staple" });
34
+ if (session.result === "session") {
35
+ // Verify the access token offline against the app's JWKS (cached per app).
36
+ const claims = await auth.verifyToken(session.access_token, { app: "my-app" });
37
+ console.log(claims.sub);
38
+
39
+ // Calls as the signed-in user carry their access token.
40
+ const me = await new Auth({ auth: { type: "bearer", token: session.access_token } }).consumer("my-app").me.retrieve();
41
+ console.log(me.email, me.email_verified_at);
42
+ }
64
43
 
65
- // Audit + invites + auth config (camelCase post-pipe)
66
- await app.auditLogs.list({ limit: "100" });
67
- await app.authConfig.update({ passwordMinLength: 12 });
44
+ try {
45
+ await app.signin({ identifier: "ada@example.com", password: "wrong" });
46
+ } catch (err) {
47
+ if (err instanceof AuthenticationError) console.log(err.code, err.requestId);
48
+ }
68
49
  ```
69
50
 
70
- ### 3. Consumer-side (BFF) — `auth.consumer(appSlug)`
71
-
72
- For backend route handlers mediating auth between your SPA and Auth. Pre-binds the appSlug.
73
-
74
- ```ts before
75
- const consumer = auth.consumer("my-app-slug");
51
+ ## Admin
76
52
 
77
- // Sign-in
78
- const { access_token, refresh_token } = await consumer.auth.signin({
79
- identifier: "alice@example.com",
80
- password: "...",
81
- });
82
-
83
- // Sign-up requires { email, password, username, display_name? } — not `identifier`.
84
- await consumer.auth.signup({
85
- email: "alice@example.com",
86
- password: "...",
87
- username: "alice",
88
- });
89
-
90
- await consumer.auth.refresh({ refresh_token });
91
- await consumer.auth.logout({ refresh_token });
92
-
93
- // Forgot password. `sendPasswordResetEmail` mints the code AND emails it
94
- // via Mail — this is what you want for a normal reset flow.
95
- await consumer.auth.sendPasswordResetEmail({ email });
96
- await consumer.auth.resetPassword({ code, new_password: "..." });
97
-
98
- // Sign in with Apple (native iOS flow). See "Federated sign-in" below.
99
- await consumer.auth.signinWithProvider({
100
- provider: "apple",
101
- id_token: identityTokenFromAppleSdk,
102
- nonce: rawNonceClientGenerated,
103
- user: { name: "Alice Doe" }, // first sign-in only
104
- });
105
-
106
- // Me — for the signed-in EndUser (pass their token via auth config)
107
- await consumer.me.getProfile();
108
- await consumer.me.listSessions();
109
- await consumer.me.revokeSession(sessionId);
53
+ ```ts
54
+ import { Auth } from "@productcraft/auth";
110
55
 
111
- // Verify (server-to-server permission checks)
112
- await consumer.verify.verify({ token });
113
- await consumer.verify.authorize({ token, permission: "billing.read" });
56
+ // Reads PRODUCTCRAFT_API_KEY from the environment.
57
+ const auth = new Auth();
58
+ const admin = auth.admin.app("5d1e2f3a-4b5c-4d6e-8f9a-0b1c2d3e4f5a");
114
59
 
115
- // M2M (client_credentials grant)
116
- await consumer.oauth.clientCredentials({ clientId, clientSecret, scope });
60
+ for await (const user of admin.endUsers.list({ limit: 100 })) {
61
+ console.log(user.id, user.email);
62
+ }
63
+ const endpoint = await admin.webhooks.endpoints.create({ url: "https://api.acme.com/webhooks/auth" });
64
+ console.log(endpoint.signing_secret); // shown once
117
65
  ```
118
66
 
119
- > **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.
120
-
121
- ### One-time codes: who sends the email?
122
-
123
- 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:
124
-
125
- | Method | Mints a code | Sends the email | Returns |
126
- | --- | --- | --- | --- |
127
- | `auth.requestVerification()` | ✅ | ❌ | `{ code, expires_at }` |
128
- | `auth.sendVerificationEmail()` | ✅ | ✅ via Mail | `{ expires_at }` |
129
- | `auth.requestReset()` | ✅ | ❌ | `{ code, expires_at }` |
130
- | `auth.sendPasswordResetEmail()` | ✅ | ✅ via Mail | `{ expires_at }` |
131
-
132
- **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.
133
-
134
- **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.
67
+ ## Verifying tokens in your backend
68
+
69
+ `auth.verifyToken(jwt, { app })` (or `auth.consumer(slug).verifyToken(jwt)`) checks the signature against the app's JWKS, the expiry, the per-app issuer (`https://api.auth.productcraft.co/<slug>`) and the audience (the slug), and refuses refresh tokens (`JwtRefreshTokenError`). Every failure is a `JwtVerifyError` subclass. For Passport, use [`@productcraft/auth-passport`](https://www.npmjs.com/package/@productcraft/auth-passport).
70
+
71
+ ## Passkeys (browser)
72
+
73
+ `auth.consumer(slug).passkeys` runs whole WebAuthn ceremonies in one call each: `enroll()`, `signIn()`, `completeMfaChallenge({ mfa_token })` and `stepUp()`. They need a browser at call time; importing is safe anywhere.
74
+
75
+ <!-- generated:start (scripts/gen-sdk.mjs; edits between the markers are overwritten) -->
76
+
77
+ ## Resources
78
+
79
+ Full reference: [api.md](./api.md).
80
+
81
+ - `auth.admin.app(…)`: `clone`, `updateStatus`
82
+ - `auth.admin.app(…).apiKeys`: `create`, `delete`, `list`, `rotate`
83
+ - `auth.admin.app(…).auditLogs`: `export`, `list`
84
+ - `auth.admin.app(…).auditSink`: `disconnect`, `replace`, `retrieve`, `rotateSecret`, `setupStatus`, `test`, `update`
85
+ - `auth.admin.app(…).authConfig`: `providers.check`, `providers.delete`, `providers.list`, `providers.replace`, `providers.retrieve`, `providers.update`, `retrieve`, `update`
86
+ - `auth.admin.app(…).credentials`: `create`, `delete`, `list`, `retrieve`, `rotate`, `update`, `updateScopes`
87
+ - `auth.admin.app(…).customDomains`: `apply`, `check`, `create`, `delete`, `export`, `list`, `retrieve`
88
+ - `auth.admin.app(…).endUserInsights`: `retrieve`, `users.retrieve`
89
+ - `auth.admin.app(…).endUsers`: `bulkDelete`, `bulkRevokeSessions`, `bulkUpdateRole`, `bulkUpdateStatus`, `contacts.create`, `contacts.delete`, `contacts.list`, `contacts.promote`, `contacts.resendVerification`, `delete`, `export`, `imports.create`, `imports.retrieve`, `invites.create`, `invites.delete`, `invites.list`, `list`, `mfa.requireReenrollment`, `resendVerification`, `retrieve`, `sessions.revokeAll`, `update`, `updateRole`, `updateStatus`
90
+ - `auth.admin.app(…).members`: `delete`, `list`
91
+ - `auth.admin.app(…).notificationSettings`: `retrieve`, `update`
92
+ - `auth.admin.app(…).notifications`: `test`
93
+ - `auth.admin.app(…).permissions`: `create`, `delete`, `enforcement.list`, `list`
94
+ - `auth.admin.app(…).roles`: `assign`, `create`, `delete`, `list`, `permissions.list`, `retrieve`, `update`, `updatePermissions`
95
+ - `auth.admin.app(…).setupStatus`: `retrieve`
96
+ - `auth.admin.app(…).signInTests`: `create`, `retrieve`
97
+ - `auth.admin.app(…).tenantApiKeys`: `config.retrieve`, `config.update`, `delete`, `list`
98
+ - `auth.admin.app(…).tenants`: `create`, `delete`, `invites.create`, `invites.delete`, `invites.list`, `list`, `members.create`, `members.delete`, `members.list`, `members.update`, `retrieve`, `update`
99
+ - `auth.admin.app(…).unverifiedAccountExpiry`: `retrieve`, `run`
100
+ - `auth.admin.app(…).webauthnConfig`: `retrieve`, `update`
101
+ - `auth.admin.app(…).webhooks`: `endpoints.create`, `endpoints.delete`, `endpoints.list`, `endpoints.retrieve`, `endpoints.rotateSecret`, `endpoints.test`, `endpoints.update`, `eventTypes.list`, `events.list`, `events.replay`, `events.retrieve`
102
+ - `auth.apps`: `create`, `delete`, `list`, `retrieve`, `update`
103
+ - `auth.consumer(…)`: `acceptInvite`, `acceptTenantInvite`, `exchangeProviderCode`, `logout`, `refresh`, `requestPasswordReset`, `requestVerification`, `resetPassword`, `sendPasswordResetEmail`, `sendVerificationEmail`, `signin`, `signinWithProvider`, `signup`, `switchTenant`, `verifyEmail`
104
+ - `auth.consumer(…).me`: `activity.list`, `changePassword`, `contacts.create`, `contacts.delete`, `contacts.list`, `contacts.promote`, `deleteAccount`, `deleteAvatar`, `mfa.factors.create`, `mfa.factors.delete`, `mfa.factors.list`, `mfa.factors.verify`, `mfa.recoveryCodes.regenerate`, `mfa.stepUp.sendCode`, `mfa.stepUp.verify`, `mfa.stepUp.webauthn.options`, `permissions`, `retrieve`, `sessions.delete`, `sessions.list`, `tenantInvites.accept`, `tenants.list`, `update`, `uploadAvatar`
105
+ - `auth.consumer(…).mfa`: `enroll`, `recover`, `sendCode`, `verify`, `verifyEnrollment`, `webauthn.options`
106
+ - `auth.consumer(…).oauth`: `introspect`, `token`
107
+ - `auth.consumer(…).passkey`: `options`, `verify`
108
+ - `auth.consumer(…).permissions`: `create`, `delete`, `list`
109
+ - `auth.consumer(…).roles`: `create`, `delete`, `list`, `retrieve`, `update`, `updatePermissions`
110
+ - `auth.consumer(…).tenantApiKeys`: `config.retrieve`, `config.update`, `create`, `delete`, `introspect`, `list`, `rotate`, `status`
111
+ - `auth.consumer(…).tenants`: `create`, `delete`, `invites.create`, `invites.delete`, `invites.list`, `list`, `members.create`, `members.delete`, `members.list`, `members.update`, `retrieve`, `update`
112
+ - `auth.consumer(…).users`: `create`, `delete`, `factors.create`, `factors.delete`, `factors.list`, `list`, `retrieve`, `update`, `updateRole`, `updateStatus`
113
+ - `auth.consumer(…).verify`: `authorize`, `authorizeBatch`, `token`
114
+ - `auth.consumer(…).wellKnown`: `jwks`, `openidConfiguration`
115
+ - `auth.stats`: `me`, `retrieve`
116
+
117
+ ## Pagination
118
+
119
+ List methods return a `PagePromise`: `for await` walks every item across pages, `await` gives one page.
135
120
 
136
- 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.
137
-
138
- 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.
139
-
140
- 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.
141
-
142
- ## Federated sign-in (Apple, Google)
143
-
144
- `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).
145
-
146
- ```ts before
147
- import { Auth, AuthHttpError } from "@productcraft/auth";
148
-
149
- const auth = new Auth();
150
- const consumer = auth.consumer("my-app-slug");
151
-
152
- const tokens = await consumer.auth.signinWithProvider({
153
- provider: "apple",
154
- // The value of `ASAuthorizationAppleIDCredential.identityToken` after
155
- // UTF-8 decoding the data; pass through your BFF unchanged.
156
- id_token: identityToken,
157
- // Raw nonce the client generated and SHA-256-hashed into the
158
- // authorize request. Auth recomputes sha256(nonce) and compares
159
- // to the token's `nonce` claim.
160
- nonce: rawNonce,
161
- // Apple sends `{ name, email }` ONLY on the very first sign-in.
162
- // Pass through on that call; omit on subsequent ones.
163
- user: { name: "Alice Doe", email: "alice@example.com" },
164
- });
121
+ ```ts
122
+ import { Auth } from "@productcraft/auth";
123
+ const auth = new Auth({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
124
+ for await (const item of auth.apps.list({ limit: 50 })) console.log(item);
125
+ const page = await auth.apps.list();
126
+ if (page.hasNextPage()) await page.getNextPage();
127
+ const all = await auth.apps.list().toArray({ maxItems: 500 });
165
128
  ```
166
129
 
167
- ### Account linking
168
-
169
- When the verified provider claim's `email` matches an existing EndUser's verified primary email, the app's `oauth_link_policy` decides the outcome:
170
-
171
- | Policy | Behavior |
172
- |---|---|
173
- | `auto` (default) | Silently link the provider identity to the existing account. Only when the provider claims `email_verified: true` AND the email is **not** an Apple private relay. |
174
- | `confirm` | Refuse with `409 link_required`. UI should sign the user in via their original method, then bind the provider identity through a follow-up flow. |
175
- | `reject` | Refuse with `409 account_exists_with_different_provider`. |
176
-
177
- Configure per-app via the auth-config endpoints (Auth-admin → "Auth config" → "OAuth link policy").
178
-
179
- ### Apple private-relay emails
130
+ ## Errors
180
131
 
181
- Apple often returns `*@privaterelay.appleid.com` for users who hide their email. Auth persists it **as-is** on the EndUser's primary email contact — you don't need to translate it on your side. Private-relay addresses never participate in `auto`-link (they're nominally verified but not deliverable through channels you control).
132
+ Every failure is a class you can catch. `APIError` carries `status`, `code`, `message`, `errors[]`, `requestId` and `docUrl`.
182
133
 
183
- ### First-sign-in name fields
184
-
185
- Apple only sends `givenName` / `familyName` on the very first sign-in. Pass them as a combined string through `user.name` on that call — Auth persists it to the EndUser's display name. Subsequent sign-ins should omit `user` entirely; Auth keeps whatever was stored on the first call.
186
-
187
- ### Error handling
188
-
189
- ```ts before
134
+ ```ts
135
+ import { Auth, NotFoundError, RateLimitError, APIError } from "@productcraft/auth";
136
+ const auth = new Auth();
190
137
  try {
191
- await consumer.auth.signinWithProvider({ provider: "apple", id_token, nonce });
138
+ await auth.apps.retrieve("00000000-0000-0000-0000-000000000000");
192
139
  } catch (err) {
193
- if (err instanceof AuthHttpError) {
194
- // err.status: 401 = bad signature / issuer / audience / nonce / expired
195
- // 409 = link_required | account_exists_with_different_provider
196
- // 4xx/5xx = other surface errors
197
- // err.data: parsed JSON body with `code` / `message` for the 409 family
198
- }
199
- throw err;
140
+ if (err instanceof NotFoundError) console.log(err.code, err.requestId, err.docUrl);
141
+ else if (err instanceof RateLimitError) console.log(err.retryAfter);
142
+ else if (err instanceof APIError) console.log(err.status, err.errors);
143
+ else throw err;
200
144
  }
201
145
  ```
202
146
 
203
- `AuthHttpError` is the same exception family every other surface method throws — one filter handles all of them.
147
+ ## Retries and timeouts
204
148
 
205
- ### Google (and future providers)
149
+ Network errors, 408, 429, 5xx and `409 IDEMPOTENCY_IN_PROGRESS` are retried twice by default with exponential backoff and jitter, honouring `Retry-After` up to 60 s. A POST or PATCH without an idempotency key is never retried. Each attempt times out after 60 s, so the worst case with defaults is about 3 minutes; pass `signal: AbortSignal.timeout(ms)` for an overall deadline.
206
150
 
207
- The Auth API uses a single `POST /{appSlug}/v1/auth/oauth/{provider}` endpoint for every supported IdP — the `provider` URL segment selects the verifier. Once Google is enabled on Auth's side, the SDK call becomes:
208
-
209
- ```ts before
210
- await consumer.auth.signinWithProvider({
211
- provider: "google",
212
- id_token: tokenFromGoogleSignInSdk,
213
- nonce: rawNonce,
214
- });
151
+ ```ts
152
+ import { Auth } from "@productcraft/auth";
153
+ const auth = new Auth({ maxRetries: 3, timeout: 20_000 });
154
+ await auth.apps.list({}, { maxRetries: 0, signal: AbortSignal.timeout(5_000) });
215
155
  ```
216
156
 
217
- No SDK upgrade required — the per-provider TS enum widens automatically with the next spec refresh.
218
-
219
- ## Passkeys (WebAuthn)
220
-
221
- Passkeys are switched on per app in the console (relying-party id + the
222
- site origin in `allowed_redirect_origins`). Once they are, each ceremony
223
- is one call on `auth.consumer(appSlug).passkeys`. The helpers run
224
- **in the browser** — they drive `navigator.credentials` — but importing
225
- the package anywhere is safe; nothing touches a browser global until you
226
- call them.
227
-
228
- ```ts before
229
- import { Auth, isPasskeySupported, PasskeyError } from "@productcraft/auth";
230
-
231
- if (!isPasskeySupported()) hidePasskeyButton(); // false in Node and in insecure contexts
232
-
233
- // Enrol — signed-in user adds a passkey as an MFA factor
234
- const me = new Auth({ auth: { type: "bearer", token: accessToken } }).consumer("acme");
235
- const { factor, recovery_codes } = await me.passkeys.enroll({ label: "MacBook" });
236
- // show recovery_codes once — they are not retrievable later
157
+ ## Idempotency
237
158
 
238
- // Passwordless sign-in — no password, no email; discoverable credential
239
- const anon = new Auth().consumer("acme");
240
- const session = await anon.passkeys.signIn({ mediation: "conditional" }); // amr: ["webauthn"]
159
+ Writes that support it send an `Idempotency-Key` automatically and reuse it on every retry. Pass your own to make a retry across processes safe. If the API's idempotency store is unavailable it runs the request without it (fail-open), so a retry during that window can execute twice.
241
160
 
242
- // MFA step of a password sign-in — when signin() answered { mfa_required, mfa_token, factors }
243
- const session2 = await anon.passkeys.completeMfaChallenge({ mfa_token: signin.mfa_token });
244
-
245
- // Step-up inside a live session
246
- const { amr, mfa_at } = await me.passkeys.stepUp();
161
+ ```ts
162
+ import { Auth } from "@productcraft/auth";
163
+ const auth = new Auth({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
164
+ const app = await auth.apps.create({
165
+ display_name: "Acme",
166
+ slug: "acme-app",
167
+ workspace_id: "835015dc-7bde-4a8a-b306-c066d4733b90",
168
+ }, { idempotencyKey: "order-1234" });
247
169
  ```
248
170
 
249
- Each helper does the options call → `navigator.credentials.create()` /
250
- `.get()` → verify call, with every buffer base64url-encoded in both
251
- directions and the `public_key` members renamed to the WebAuthn spec's
252
- camelCase (the Auth wire is snake_case). Browser-side failures reject
253
- with `PasskeyError` (`code`: `unsupported`, `cancelled`, `timeout`,
254
- `invalid_state`, `security`, `no_credential`, `unknown`; `cause` holds
255
- the DOM error); server-side failures stay `AuthHttpError`, so a 401 on
256
- a forged or replayed assertion is distinguishable from the user
257
- dismissing the prompt. Pass `signal: AbortSignal.timeout(ms)` to bound
258
- the prompt yourself.
259
-
260
- The encoding layer is exported for anyone who wants the pieces:
261
- `toB64u`, `fromB64u`, `decodeOptions`, `encodeCredential`, plus
262
- `createPasskeyCredential(public_key)` / `getPasskeyCredential(public_key)`
263
- which wrap `navigator.credentials` on their own.
264
-
265
- `signIn()` never sends an identifier: `/auth/passkey/options` takes none
266
- by design (it would be an account-existence oracle), so sign-in relies on
267
- a discoverable (resident) credential.
268
-
269
- ## JWT verification
270
-
271
- 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.
171
+ ## Webhooks
272
172
 
273
- ### Token claim shape
173
+ `auth.webhooks.unwrap()` verifies the signature against the **raw** body and returns a typed `Auth.UnwrapWebhookEvent`. The secret comes from `{ secret }`, the client's `webhookSecret`, or `PRODUCTCRAFT_AUTH_WEBHOOK_SECRET`.
274
174
 
275
- Auth EndUser access tokens carry, at minimum:
276
-
277
- | Claim | Example | Notes |
278
- |---|---|---|
279
- | `alg` (header) | `RS256` | Signing algorithm — RS256 from the per-app keystore. |
280
- | `kid` (header) | `rs256-<key-id>` | Selects the public key from the per-app JWKS. |
281
- | `iss` | `https://api.auth.productcraft.co/<appSlug>` | Per-app issuer URL. Available on the SDK as `scope.expectedIssuer`. |
282
- | `aud` | `<appSlug>` | The app slug (literal). Available as `scope.expectedAudience`. |
283
- | `sub` | `<account-uuid>` | The EndUser's account id — what your local user table should key on. |
284
- | `aid` | `<app-uuid>` | The Auth app uuid (matches `scope.appId` if you held it). |
285
- | `sid` | `<session-uuid>` | Session row id — useful for revoke-this-session ops. |
286
- | `role` | `member` | The EndUser's role in this app. |
287
- | `type` | `end_user` \| `m2m` | Distinguishes EndUser tokens from M2M (client_credentials) tokens. |
288
- | `amr` | `["pwd"]` \| `["oauth.apple"]` | Auth-method-reference array — how this session was established. |
289
- | `iat`, `exp` | unix seconds | Standard. |
290
-
291
- Tokens issued before the 2026-05-24 per-app-issuer migration carried `iss: "heimdall"` (no URL) and **no** `aud` claim. `scope.acceptedIssuers` includes both the new URL form and the legacy literal so existing tokens keep verifying through their TTL; `scope.verifyToken(token)` handles this automatically. If you wire `jose.jwtVerify` directly, pass `issuer: scope.acceptedIssuers` (an array) for the transition window, or `issuer: scope.expectedIssuer` once you're sure no legacy tokens are live.
292
-
293
- ### One-line verify (the 80% case)
294
-
295
- ```ts before
296
- import { Auth, JwtExpiredError } from "@productcraft/auth";
175
+ Next.js route handler:
297
176
 
177
+ ```ts
178
+ import { Auth } from "@productcraft/auth";
298
179
  const auth = new Auth();
299
- const scope = auth.consumer("my-app-slug");
300
-
301
- try {
302
- const claims = await scope.verifyToken(token);
303
- // claims.sub, claims.aid, claims.sid, claims.role, claims.amr, ...
304
- } catch (err) {
305
- if (err instanceof JwtExpiredError) { /* trigger refresh */ }
306
- throw err;
180
+ export async function POST(req: Request) {
181
+ const event = await auth.webhooks.unwrap({ payload: await req.text(), headers: req.headers });
182
+ switch (event.type) {
183
+ case "app.slug_changed":
184
+ console.log(event.data);
185
+ break;
186
+ default:
187
+ break; // UnknownWebhookEvent: types added later
188
+ }
189
+ return new Response(null, { status: 204 });
307
190
  }
308
191
  ```
309
192
 
310
- Behind the scenes: JWKS fetched once, cached in-memory (10 min TTL by default), singleflighted so 100 concurrent verifies do 1 fetch, auto-refetched if a token's `kid` isn't in the cached JWKS (rotation handling). Issuer + audience are checked against `scope.acceptedIssuers` + `scope.expectedAudience` automatically.
311
-
312
- **Refresh tokens are refused.** A token carrying `token_use: "refresh"` (or the older `type: "refresh"`) throws `JwtRefreshTokenError` (code `ERR_JWT_REFRESH_TOKEN`) even when its signature, issuer and audience are valid: a refresh token is only good at the refresh endpoint, never as an access token. `createAuthJwtStrategy` from `@productcraft/auth-passport` refuses them too. If you call `jwtVerify` yourself (below), check `isRefreshToken(payload)` and reject.
313
-
314
- ### Building blocks for `jose`, NestJS guards, Hono, etc.
315
-
316
- `scope.jwks.getKey` is a [jose-compatible](https://github.com/panva/jose) key resolver — pass it anywhere a `GetKeyFunction` is expected.
193
+ Express (keep the body raw):
317
194
 
318
- ```ts before
319
- import { jwtVerify } from "jose";
320
-
321
- const scope = auth.consumer("my-app-slug");
322
- const { payload } = await jwtVerify(token, scope.jwks.getKey, {
323
- issuer: scope.expectedIssuer, // "https://api.auth.productcraft.co/<appSlug>"
324
- audience: scope.expectedAudience, // "<appSlug>"
325
- algorithms: ["RS256"],
195
+ ```ts
196
+ import express from "express";
197
+ import { Auth } from "@productcraft/auth";
198
+ const auth = new Auth();
199
+ const app = express();
200
+ app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
201
+ const event = await auth.webhooks.unwrap({ payload: req.body, headers: req.headers });
202
+ console.log(event.type);
203
+ res.sendStatus(204);
326
204
  });
327
205
  ```
328
206
 
329
- For the per-app boundary, the JWKS is the cryptographic gate (only the app's keystore can sign these), but pinning `iss` + `aud` rejects a token minted for a *different* app on the same platform before the signature ever gets checked — defence in depth at a higher layer.
207
+ Hono:
330
208
 
331
- ### Passport integration
332
-
333
- Use the companion package [`@productcraft/auth-passport`](https://github.com/clauderanelagh/productcraft-node/tree/main/packages/auth-passport):
334
-
335
- ```bash
336
- npm install @productcraft/auth-passport passport-jwt
209
+ ```ts
210
+ import { Hono } from "hono";
211
+ import { Auth } from "@productcraft/auth";
212
+ const auth = new Auth();
213
+ const app = new Hono();
214
+ app.post("/webhooks", async (c) => {
215
+ const event = await auth.webhooks.unwrap({ payload: await c.req.text(), headers: c.req.raw.headers });
216
+ console.log(event.type);
217
+ return c.body(null, 204);
218
+ });
337
219
  ```
338
220
 
339
- ```ts before
340
- import passportJwt from "passport-jwt";
341
- import { createPassportSecretOrKeyProvider } from "@productcraft/auth-passport";
342
-
343
- const scope = auth.consumer("my-app-slug");
344
- new passportJwt.Strategy(
345
- {
346
- jwtFromRequest: passportJwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
347
- secretOrKeyProvider: createPassportSecretOrKeyProvider(scope),
348
- issuer: scope.expectedIssuer, // per-app URL
349
- audience: scope.expectedAudience, // appSlug
350
- algorithms: ["RS256"],
351
- },
352
- (payload, done) => done(null, payload),
353
- );
354
- ```
221
+ ## Raw responses
355
222
 
356
- ## Error handling
223
+ Every returned object has a non-enumerable `lastResponse` (`requestId`, `status`, `headers`). `.withResponse()` adds the `Response` and parsed rate-limit headers.
357
224
 
358
225
  ```ts
359
- import {
360
- JwtVerifyError, // base — catch this if you don't care which sub-kind
361
- JwtInvalidError,
362
- JwtExpiredError,
363
- JwtNotYetValidError,
364
- JwtIssuerMismatchError,
365
- JwtAudienceMismatchError,
366
- JwksKeyNotFoundError,
367
- JwksFetchError,
368
- AuthHttpError, // any non-2xx HTTP response
369
- } from "@productcraft/auth";
226
+ import { Auth } from "@productcraft/auth";
227
+ const auth = new Auth({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
228
+ const id = "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b";
229
+ const obj = await auth.apps.retrieve(id);
230
+ console.log(obj.lastResponse.requestId);
231
+ const { data, response, requestId, rateLimit } = await auth.apps.retrieve(id).withResponse();
232
+ console.log(data, response.status, requestId, rateLimit?.remaining);
370
233
  ```
371
234
 
372
- ## Credentials from the environment
373
-
374
- Server-side, omit `auth` and the client reads your workspace key from the environment:
235
+ ## Testing
375
236
 
376
- ```bash
377
- # .env
378
- PRODUCTCRAFT_API_KEY=pcft_live_...
379
- PRODUCTCRAFT_AUTH_APP=acme # default slug for auth.consumer()
380
- ```
237
+ `@productcraft/auth/testing` exports `createMockFetch()`, a `fetch` that answers every operation with its spec example. Override one operation by name.
381
238
 
382
- - An explicit `auth` always wins over the environment.
383
- - Hosts are never read from the environment. Override them in code with `baseUrl`.
384
- - This is a server-only fallback. Never put a key in `NEXT_PUBLIC_*` or any other variable a bundler inlines into browser code.
385
- - `auth.app(appId)` and the workspace methods (`apps`, `stats`) use the explicit `auth`, else `PRODUCTCRAFT_API_KEY`.
386
- - `auth.consumer()` takes its slug from `PRODUCTCRAFT_AUTH_APP` when you omit it, and **never** reads a credential from the environment: consumer routes act for an end user, so only an explicit `auth` is sent. PAK-only consumer routes (`signup_requires_pak` sign-ups, tenants, code mint, factors) therefore need `new Auth({ auth: { type: "apiKey", key: process.env.MY_APP_PAK! } })`.
387
-
388
- ## Configuration
389
-
390
- ```ts before
391
- new Auth({
392
- // Auth credential the SDK presents to Auth
393
- // Omit to use PRODUCTCRAFT_API_KEY from the environment
394
- auth: { type: "apiKey", key: "pcft_live_..." }
395
- | { type: "bearer", token: "eyJ..." }
396
- | { type: "cookie", value: "auth_token=..." },
397
- // Override the prod base URL — useful for dev / staging
398
- baseUrl: "https://api.auth.example.test",
399
- // Custom fetch (undici with retry, mock in tests, ...)
400
- fetch: customFetch,
401
- // JWKS cache TTL — default 10 minutes
402
- jwksTtlMs: 10 * 60 * 1000,
403
- });
239
+ ```ts
240
+ import { Auth } from "@productcraft/auth";
241
+ import { createMockFetch } from "@productcraft/auth/testing";
242
+ const fetch = createMockFetch();
243
+ const auth = new Auth({ apiKey: "test", workspaceId: "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b", fetch });
244
+ await auth.apps.list();
245
+ console.log(fetch.calls.length);
404
246
  ```
405
247
 
406
- ## How this SDK is built
407
-
408
- Generated from the live OpenAPI spec at `https://api.auth.productcraft.co/docs-json`. The 4,000+ lines of types + per-operation client functions in `src/_generated/` are produced by [kubb](https://kubb.dev/); the thin resource classes (~600 lines of `app.ts` / `consumer.ts`) wrap those into the namespace structure shown above.
248
+ ## Escape hatch
409
249
 
410
- When the spec changes, the nightly `spec-refresh` workflow re-runs codegen and opens a PR with the diff. Type-safe consumers get the latest API surface automatically.
250
+ `.client` is the typed `openapi-fetch` client over the same credential and base URL (no retries). Use it for anything the resource methods do not cover.
411
251
 
412
- ## License
252
+ ```ts
253
+ import { Auth } from "@productcraft/auth";
254
+ const auth = new Auth({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
255
+ const { data, error } = await auth.client.GET("/v1/apps");
256
+ console.log(data, error);
257
+ ```
413
258
 
414
- [MIT](https://github.com/clauderanelagh/productcraft-node/blob/main/LICENSE).
259
+ <!-- generated:end -->