@productcraft/auth 0.5.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ProductCraft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # @productcraft/auth
2
+
3
+ Typed Node.js SDK for the [ProductCraft Auth](https://productcraft.co) auth platform.
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";
15
+
16
+ const auth = new Auth({
17
+ auth: { type: "apiKey", key: process.env.PCFT_KEY! },
18
+ });
19
+ ```
20
+
21
+ The SDK splits into three caller contexts.
22
+
23
+ ### 1. Workspace-wide admin
24
+
25
+ ```ts
26
+ // /v1/apps, /v1/stats/me
27
+ const apps = await auth.apps.list();
28
+
29
+ // Create requires display_name + workspace_id (not `name`).
30
+ await auth.apps.create({
31
+ display_name: "My App",
32
+ slug: "my-app",
33
+ workspace_id: "<workspace-uuid>",
34
+ });
35
+
36
+ const stats = await auth.stats.get();
37
+ ```
38
+
39
+ ### 2. App-scoped admin — `auth.app(appId)`
40
+
41
+ Pre-binds the appId path param so resource methods read like `app.endUsers.list()`.
42
+
43
+ ```ts
44
+ const app = auth.app("<app-uuid>");
45
+
46
+ // EndUsers — profile updates only carry { display_name?, email? }.
47
+ // Status / role transitions are separate calls.
48
+ const users = await app.endUsers.list({ limit: "20", cursor: "..." });
49
+ await app.endUsers.update(userId, { display_name: "Alice Smith" });
50
+ await app.endUsers.updateStatus(userId, { status: "active" });
51
+ await app.endUsers.revokeAllSessions(userId);
52
+
53
+ // Roles — `CreateRoleDto` is { name, description? }. Permissions are
54
+ // bound separately, after the role exists.
55
+ await app.roles.create({ name: "admin", description: "Billing admin" });
56
+ await app.roles.setPermissions("admin", { permissions: ["billing.read"] });
57
+ await app.roles.assign({ userId, roleName: "admin" });
58
+ await app.permissions.list();
59
+
60
+ // API keys — permissions[] is required even if empty.
61
+ await app.apiKeys.create({ name: "ci", permissions: [] });
62
+
63
+ // M2M credentials
64
+ const m2m = await app.credentials.create({ name: "backend-svc" });
65
+
66
+ // Audit + invites + auth config (camelCase post-pipe)
67
+ await app.auditLogs.list({ limit: "100" });
68
+ await app.authConfig.update({ passwordMinLength: 12 });
69
+ ```
70
+
71
+ ### 3. Consumer-side (BFF) — `auth.consumer(appSlug)`
72
+
73
+ For backend route handlers mediating auth between your SPA and Auth. Pre-binds the appSlug.
74
+
75
+ ```ts
76
+ const consumer = auth.consumer("my-app-slug");
77
+
78
+ // Sign-in
79
+ const { access_token, refresh_token } = await consumer.auth.signin({
80
+ identifier: "alice@example.com",
81
+ password: "...",
82
+ });
83
+
84
+ // Sign-up requires { email, password, username, display_name? } — not `identifier`.
85
+ await consumer.auth.signup({
86
+ email: "alice@example.com",
87
+ password: "...",
88
+ username: "alice",
89
+ });
90
+
91
+ await consumer.auth.refresh({ refresh_token });
92
+ await consumer.auth.logout({ refresh_token });
93
+ await consumer.auth.requestReset({ email });
94
+ await consumer.auth.resetPassword({ token, new_password: "..." });
95
+
96
+ // Sign in with Apple (native iOS flow). See "Federated sign-in" below.
97
+ await consumer.auth.signinWithProvider({
98
+ provider: "apple",
99
+ id_token: identityTokenFromAppleSdk,
100
+ nonce: rawNonceClientGenerated,
101
+ user: { name: "Alice Doe" }, // first sign-in only
102
+ });
103
+
104
+ // Me — for the signed-in EndUser (pass their token via auth config)
105
+ await consumer.me.getProfile();
106
+ await consumer.me.listSessions();
107
+ await consumer.me.revokeSession(sessionId);
108
+
109
+ // Verify (server-to-server permission checks)
110
+ await consumer.verify.verify({ token });
111
+ await consumer.verify.authorize({ token, permission: "billing.read" });
112
+
113
+ // M2M (client_credentials grant)
114
+ await consumer.oauth.clientCredentials({ clientId, clientSecret, scope });
115
+ ```
116
+
117
+ > **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
+
119
+ ## Federated sign-in (Apple, Google)
120
+
121
+ `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).
122
+
123
+ ```ts
124
+ import { Auth, AuthHttpError } from "@productcraft/auth";
125
+
126
+ const auth = new Auth();
127
+ const consumer = auth.consumer("my-app-slug");
128
+
129
+ const tokens = await consumer.auth.signinWithProvider({
130
+ provider: "apple",
131
+ // The value of `ASAuthorizationAppleIDCredential.identityToken` after
132
+ // UTF-8 decoding the data; pass through your BFF unchanged.
133
+ id_token: identityToken,
134
+ // Raw nonce the client generated and SHA-256-hashed into the
135
+ // authorize request. Auth recomputes sha256(nonce) and compares
136
+ // to the token's `nonce` claim.
137
+ nonce: rawNonce,
138
+ // Apple sends `{ name, email }` ONLY on the very first sign-in.
139
+ // Pass through on that call; omit on subsequent ones.
140
+ user: { name: "Alice Doe", email: "alice@example.com" },
141
+ });
142
+ ```
143
+
144
+ ### Account linking
145
+
146
+ When the verified provider claim's `email` matches an existing EndUser's verified primary email, the app's `oauth_link_policy` decides the outcome:
147
+
148
+ | Policy | Behavior |
149
+ |---|---|
150
+ | `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. |
151
+ | `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. |
152
+ | `reject` | Refuse with `409 account_exists_with_different_provider`. |
153
+
154
+ Configure per-app via the auth-config endpoints (Auth-admin → "Auth config" → "OAuth link policy").
155
+
156
+ ### Apple private-relay emails
157
+
158
+ 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).
159
+
160
+ ### First-sign-in name fields
161
+
162
+ 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.
163
+
164
+ ### Error handling
165
+
166
+ ```ts
167
+ try {
168
+ await consumer.auth.signinWithProvider({ provider: "apple", id_token, nonce });
169
+ } catch (err) {
170
+ if (err instanceof AuthHttpError) {
171
+ // err.status: 401 = bad signature / issuer / audience / nonce / expired
172
+ // 409 = link_required | account_exists_with_different_provider
173
+ // 4xx/5xx = other surface errors
174
+ // err.data: parsed JSON body with `code` / `message` for the 409 family
175
+ }
176
+ throw err;
177
+ }
178
+ ```
179
+
180
+ `AuthHttpError` is the same exception family every other surface method throws — one filter handles all of them.
181
+
182
+ ### Google (and future providers)
183
+
184
+ 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:
185
+
186
+ ```ts
187
+ await consumer.auth.signinWithProvider({
188
+ provider: "google",
189
+ id_token: tokenFromGoogleSignInSdk,
190
+ nonce: rawNonce,
191
+ });
192
+ ```
193
+
194
+ No SDK upgrade required — the per-provider TS enum widens automatically with the next spec refresh.
195
+
196
+ ## JWT verification
197
+
198
+ 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.
199
+
200
+ ### Token claim shape
201
+
202
+ Auth EndUser access tokens carry, at minimum:
203
+
204
+ | Claim | Example | Notes |
205
+ |---|---|---|
206
+ | `alg` (header) | `RS256` | Signing algorithm — RS256 from the per-app keystore. |
207
+ | `kid` (header) | `rs256-<key-id>` | Selects the public key from the per-app JWKS. |
208
+ | `iss` | `https://api.auth.productcraft.co/<appSlug>` | Per-app issuer URL. Available on the SDK as `scope.expectedIssuer`. |
209
+ | `aud` | `<appSlug>` | The app slug (literal). Available as `scope.expectedAudience`. |
210
+ | `sub` | `<account-uuid>` | The EndUser's account id — what your local user table should key on. |
211
+ | `aid` | `<app-uuid>` | The Auth app uuid (matches `scope.appId` if you held it). |
212
+ | `sid` | `<session-uuid>` | Session row id — useful for revoke-this-session ops. |
213
+ | `role` | `member` | The EndUser's role in this app. |
214
+ | `type` | `end_user` \| `m2m` | Distinguishes EndUser tokens from M2M (client_credentials) tokens. |
215
+ | `amr` | `["pwd"]` \| `["oauth.apple"]` | Auth-method-reference array — how this session was established. |
216
+ | `iat`, `exp` | unix seconds | Standard. |
217
+
218
+ 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.
219
+
220
+ ### One-line verify (the 80% case)
221
+
222
+ ```ts
223
+ import { Auth, JwtExpiredError } from "@productcraft/auth";
224
+
225
+ const auth = new Auth();
226
+ const scope = auth.consumer("my-app-slug");
227
+
228
+ try {
229
+ const claims = await scope.verifyToken(token);
230
+ // claims.sub, claims.aid, claims.sid, claims.role, claims.amr, ...
231
+ } catch (err) {
232
+ if (err instanceof JwtExpiredError) { /* trigger refresh */ }
233
+ throw err;
234
+ }
235
+ ```
236
+
237
+ 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.
238
+
239
+ ### Building blocks for `jose`, NestJS guards, Hono, etc.
240
+
241
+ `scope.jwks.getKey` is a [jose-compatible](https://github.com/panva/jose) key resolver — pass it anywhere a `GetKeyFunction` is expected.
242
+
243
+ ```ts
244
+ import { jwtVerify } from "jose";
245
+
246
+ const scope = auth.consumer("my-app-slug");
247
+ const { payload } = await jwtVerify(token, scope.jwks.getKey, {
248
+ issuer: scope.expectedIssuer, // "https://api.auth.productcraft.co/<appSlug>"
249
+ audience: scope.expectedAudience, // "<appSlug>"
250
+ algorithms: ["RS256"],
251
+ });
252
+ ```
253
+
254
+ 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.
255
+
256
+ ### Passport integration
257
+
258
+ Use the companion package [`@productcraft/auth-passport`](../auth-passport):
259
+
260
+ ```bash
261
+ npm install @productcraft/auth-passport passport-jwt
262
+ ```
263
+
264
+ ```ts
265
+ import passportJwt from "passport-jwt";
266
+ import { createPassportSecretOrKeyProvider } from "@productcraft/auth-passport";
267
+
268
+ const scope = auth.consumer("my-app-slug");
269
+ new passportJwt.Strategy(
270
+ {
271
+ jwtFromRequest: passportJwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
272
+ secretOrKeyProvider: createPassportSecretOrKeyProvider(scope),
273
+ issuer: scope.expectedIssuer, // per-app URL
274
+ audience: scope.expectedAudience, // appSlug
275
+ algorithms: ["RS256"],
276
+ },
277
+ (payload, done) => done(null, payload),
278
+ );
279
+ ```
280
+
281
+ ## Error handling
282
+
283
+ ```ts
284
+ import {
285
+ JwtVerifyError, // base — catch this if you don't care which sub-kind
286
+ JwtInvalidError,
287
+ JwtExpiredError,
288
+ JwtNotYetValidError,
289
+ JwtIssuerMismatchError,
290
+ JwtAudienceMismatchError,
291
+ JwksKeyNotFoundError,
292
+ JwksFetchError,
293
+ AuthHttpError, // any non-2xx HTTP response
294
+ } from "@productcraft/auth";
295
+ ```
296
+
297
+ ## Configuration
298
+
299
+ ```ts
300
+ new Auth({
301
+ // Auth credential the SDK presents to Auth
302
+ auth: { type: "apiKey", key: "pcft_live_..." }
303
+ | { type: "bearer", token: "eyJ..." }
304
+ | { type: "cookie", value: "auth_token=..." },
305
+ // Override the prod base URL — useful for dev / staging
306
+ baseUrl: "https://api.auth.example.test",
307
+ // Custom fetch (undici with retry, mock in tests, ...)
308
+ fetch: customFetch,
309
+ // JWKS cache TTL — default 10 minutes
310
+ jwksTtlMs: 10 * 60 * 1000,
311
+ });
312
+ ```
313
+
314
+ ## How this SDK is built
315
+
316
+ 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.
317
+
318
+ 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.
319
+
320
+ ## License
321
+
322
+ [MIT](../../LICENSE).