@productcraft/auth 0.9.0 → 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 +194 -349
- package/api.md +5182 -0
- package/dist/index.cjs +7175 -2521
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +35974 -3282
- package/dist/index.js +5711 -2565
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +2328 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.js +2326 -0
- package/dist/testing.js.map +1 -0
- package/manifest.json +3215 -0
- package/package.json +16 -6
- package/dist/index.d.cts +0 -3759
package/README.md
CHANGED
|
@@ -1,414 +1,259 @@
|
|
|
1
1
|
# @productcraft/auth
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
+
```sh
|
|
10
|
+
npm install @productcraft/auth
|
|
36
11
|
```
|
|
37
12
|
|
|
38
|
-
|
|
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
|
-
|
|
15
|
+
## Two scopes
|
|
41
16
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
26
|
+
```ts
|
|
27
|
+
import { Auth, AuthenticationError } from "@productcraft/auth";
|
|
61
28
|
|
|
62
|
-
|
|
63
|
-
const
|
|
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
|
-
|
|
66
|
-
await app.
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
Every failure is a class you can catch. `APIError` carries `status`, `code`, `message`, `errors[]`, `requestId` and `docUrl`.
|
|
182
133
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
138
|
+
await auth.apps.retrieve("00000000-0000-0000-0000-000000000000");
|
|
192
139
|
} catch (err) {
|
|
193
|
-
if (err instanceof
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
147
|
+
## Retries and timeouts
|
|
204
148
|
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
|
319
|
-
import
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
207
|
+
Hono:
|
|
330
208
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
##
|
|
373
|
-
|
|
374
|
-
Server-side, omit `auth` and the client reads your workspace key from the environment:
|
|
235
|
+
## Testing
|
|
375
236
|
|
|
376
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
<!-- generated:end -->
|