@productcraft/heimdall 0.0.2 → 0.1.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/README.md +281 -0
- package/dist/index.cjs +1983 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2339 -2827
- package/dist/index.d.ts +2339 -2827
- package/dist/index.js +1972 -4
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/README.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# @productcraft/heimdall
|
|
2
|
+
|
|
3
|
+
Typed Node.js SDK for the [ProductCraft Heimdall](https://productcraft.co) auth platform.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @productcraft/heimdall
|
|
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 { Heimdall } from "@productcraft/heimdall";
|
|
15
|
+
|
|
16
|
+
const heimdall = new Heimdall({
|
|
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/idp/*, /v1/stats/me
|
|
27
|
+
const apps = await heimdall.apps.list();
|
|
28
|
+
await heimdall.apps.create({ name: "My App", slug: "my-app" });
|
|
29
|
+
await heimdall.idp.list();
|
|
30
|
+
await heimdall.stats.get();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 2. App-scoped admin — `heimdall.app(appId)`
|
|
34
|
+
|
|
35
|
+
Pre-binds the appId path param so resource methods read like `app.endUsers.list()`.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const app = heimdall.app("app_xyz_uuid");
|
|
39
|
+
|
|
40
|
+
// EndUsers
|
|
41
|
+
const users = await app.endUsers.list({ limit: "20", cursor: "..." });
|
|
42
|
+
await app.endUsers.update(userId, { status: "active" });
|
|
43
|
+
await app.endUsers.revokeAllSessions(userId);
|
|
44
|
+
|
|
45
|
+
// Roles / Permissions
|
|
46
|
+
await app.roles.create({ name: "admin", permissions: ["billing.read"] });
|
|
47
|
+
await app.roles.assign({ userId, roleName: "admin" });
|
|
48
|
+
await app.permissions.list();
|
|
49
|
+
|
|
50
|
+
// API keys + M2M creds
|
|
51
|
+
await app.apiKeys.create({ name: "ci" });
|
|
52
|
+
const m2m = await app.credentials.create({ name: "backend-svc" });
|
|
53
|
+
|
|
54
|
+
// Audit + invites + auth config
|
|
55
|
+
await app.auditLogs.list({ limit: "100" });
|
|
56
|
+
await app.authConfig.update({ passwordPolicy: { minLength: 12 } });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 3. Consumer-side (BFF) — `heimdall.consumer(appSlug)`
|
|
60
|
+
|
|
61
|
+
For backend route handlers mediating auth between your SPA and Heimdall. Pre-binds the appSlug.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const consumer = heimdall.consumer("my-app-slug");
|
|
65
|
+
|
|
66
|
+
// Sign-in flows
|
|
67
|
+
const { access_token, refresh_token } = await consumer.auth.signin({
|
|
68
|
+
identifier: "alice@example.com",
|
|
69
|
+
password: "...",
|
|
70
|
+
});
|
|
71
|
+
await consumer.auth.signup({ identifier, password });
|
|
72
|
+
await consumer.auth.refresh({ refresh_token });
|
|
73
|
+
await consumer.auth.logout({ refresh_token });
|
|
74
|
+
await consumer.auth.requestReset({ email });
|
|
75
|
+
await consumer.auth.resetPassword({ code, newPassword });
|
|
76
|
+
|
|
77
|
+
// Sign in with Apple (native iOS flow). See "Federated sign-in" below.
|
|
78
|
+
await consumer.auth.signinWithProvider({
|
|
79
|
+
provider: "apple",
|
|
80
|
+
id_token: identityTokenFromAppleSdk,
|
|
81
|
+
nonce: rawNonceClientGenerated,
|
|
82
|
+
user: { name: "Alice Doe" }, // first sign-in only
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Me — for the signed-in EndUser (pass their token via auth config)
|
|
86
|
+
await consumer.me.getProfile();
|
|
87
|
+
await consumer.me.listSessions();
|
|
88
|
+
await consumer.me.revokeSession(sessionId);
|
|
89
|
+
|
|
90
|
+
// Verify (server-to-server permission checks)
|
|
91
|
+
await consumer.verify.verify({ token });
|
|
92
|
+
await consumer.verify.authorize({ token, permission: "billing.read" });
|
|
93
|
+
|
|
94
|
+
// M2M (client_credentials grant)
|
|
95
|
+
await consumer.oauth.clientCredentials({ clientId, clientSecret, scope });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> **Wire-shape note.** Heimdall'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.
|
|
99
|
+
|
|
100
|
+
## Federated sign-in (Apple, Google)
|
|
101
|
+
|
|
102
|
+
`consumer.auth.signinWithProvider` lets a BFF exchange a provider-issued identity token for a Heimdall `{ 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 Heimdall'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).
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { Heimdall, HeimdallHttpError } from "@productcraft/heimdall";
|
|
106
|
+
|
|
107
|
+
const heimdall = new Heimdall();
|
|
108
|
+
const consumer = heimdall.consumer("my-app-slug");
|
|
109
|
+
|
|
110
|
+
const tokens = await consumer.auth.signinWithProvider({
|
|
111
|
+
provider: "apple",
|
|
112
|
+
// The value of `ASAuthorizationAppleIDCredential.identityToken` after
|
|
113
|
+
// UTF-8 decoding the data; pass through your BFF unchanged.
|
|
114
|
+
id_token: identityToken,
|
|
115
|
+
// Raw nonce the client generated and SHA-256-hashed into the
|
|
116
|
+
// authorize request. Heimdall recomputes sha256(nonce) and compares
|
|
117
|
+
// to the token's `nonce` claim.
|
|
118
|
+
nonce: rawNonce,
|
|
119
|
+
// Apple sends `{ name, email }` ONLY on the very first sign-in.
|
|
120
|
+
// Pass through on that call; omit on subsequent ones.
|
|
121
|
+
user: { name: "Alice Doe", email: "alice@example.com" },
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Account linking
|
|
126
|
+
|
|
127
|
+
When the verified provider claim's `email` matches an existing EndUser's verified primary email, the app's `oauth_link_policy` decides the outcome:
|
|
128
|
+
|
|
129
|
+
| Policy | Behavior |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `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. |
|
|
132
|
+
| `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. |
|
|
133
|
+
| `reject` | Refuse with `409 account_exists_with_different_provider`. |
|
|
134
|
+
|
|
135
|
+
Configure per-app via the auth-config endpoints (Heimdall-admin → "Auth config" → "OAuth link policy").
|
|
136
|
+
|
|
137
|
+
### Apple private-relay emails
|
|
138
|
+
|
|
139
|
+
Apple often returns `*@privaterelay.appleid.com` for users who hide their email. Heimdall 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).
|
|
140
|
+
|
|
141
|
+
### First-sign-in name fields
|
|
142
|
+
|
|
143
|
+
Apple only sends `givenName` / `familyName` on the very first sign-in. Pass them as a combined string through `user.name` on that call — Heimdall persists it to the EndUser's display name. Subsequent sign-ins should omit `user` entirely; Heimdall keeps whatever was stored on the first call.
|
|
144
|
+
|
|
145
|
+
### Error handling
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
try {
|
|
149
|
+
await consumer.auth.signinWithProvider({ provider: "apple", id_token, nonce });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err instanceof HeimdallHttpError) {
|
|
152
|
+
// err.status: 401 = bad signature / issuer / audience / nonce / expired
|
|
153
|
+
// 409 = link_required | account_exists_with_different_provider
|
|
154
|
+
// 4xx/5xx = other surface errors
|
|
155
|
+
// err.data: parsed JSON body with `code` / `message` for the 409 family
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`HeimdallHttpError` is the same exception family every other surface method throws — one filter handles all of them.
|
|
162
|
+
|
|
163
|
+
### Google (and future providers)
|
|
164
|
+
|
|
165
|
+
The Heimdall 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 Heimdall's side, the SDK call becomes:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
await consumer.auth.signinWithProvider({
|
|
169
|
+
provider: "google",
|
|
170
|
+
id_token: tokenFromGoogleSignInSdk,
|
|
171
|
+
nonce: rawNonce,
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
No SDK upgrade required — the per-provider TS enum widens automatically with the next spec refresh.
|
|
176
|
+
|
|
177
|
+
## JWT verification
|
|
178
|
+
|
|
179
|
+
Every Heimdall 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.
|
|
180
|
+
|
|
181
|
+
### One-line verify (the 80% case)
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { Heimdall, JwtExpiredError } from "@productcraft/heimdall";
|
|
185
|
+
|
|
186
|
+
const heimdall = new Heimdall();
|
|
187
|
+
const scope = heimdall.consumer("my-app-slug");
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const claims = await scope.verifyToken(token);
|
|
191
|
+
// claims.sub, claims.email, claims.role, claims.permissions, ...
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof JwtExpiredError) { /* trigger refresh */ }
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
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).
|
|
199
|
+
|
|
200
|
+
### Building blocks for `jose`, NestJS guards, Hono, etc.
|
|
201
|
+
|
|
202
|
+
`scope.jwks.getKey` is a [jose-compatible](https://github.com/panva/jose) key resolver — pass it anywhere a `GetKeyFunction` is expected.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { jwtVerify } from "jose";
|
|
206
|
+
|
|
207
|
+
const scope = heimdall.consumer("my-app-slug");
|
|
208
|
+
const { payload } = await jwtVerify(token, scope.jwks.getKey, {
|
|
209
|
+
issuer: scope.expectedIssuer,
|
|
210
|
+
audience: "my-app",
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Passport integration
|
|
215
|
+
|
|
216
|
+
Use the companion package [`@productcraft/heimdall-passport`](../heimdall-passport):
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
npm install @productcraft/heimdall-passport passport-jwt
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import passportJwt from "passport-jwt";
|
|
224
|
+
import { createPassportSecretOrKeyProvider } from "@productcraft/heimdall-passport";
|
|
225
|
+
|
|
226
|
+
const scope = heimdall.consumer("my-app-slug");
|
|
227
|
+
new passportJwt.Strategy(
|
|
228
|
+
{
|
|
229
|
+
jwtFromRequest: passportJwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
230
|
+
secretOrKeyProvider: createPassportSecretOrKeyProvider(scope),
|
|
231
|
+
issuer: scope.expectedIssuer,
|
|
232
|
+
algorithms: ["ES256"],
|
|
233
|
+
},
|
|
234
|
+
(payload, done) => done(null, payload),
|
|
235
|
+
);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Error handling
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import {
|
|
242
|
+
JwtVerifyError, // base — catch this if you don't care which sub-kind
|
|
243
|
+
JwtInvalidError,
|
|
244
|
+
JwtExpiredError,
|
|
245
|
+
JwtNotYetValidError,
|
|
246
|
+
JwtIssuerMismatchError,
|
|
247
|
+
JwtAudienceMismatchError,
|
|
248
|
+
JwksKeyNotFoundError,
|
|
249
|
+
JwksFetchError,
|
|
250
|
+
HeimdallHttpError, // any non-2xx HTTP response
|
|
251
|
+
} from "@productcraft/heimdall";
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Configuration
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
new Heimdall({
|
|
258
|
+
// Auth credential the SDK presents to Heimdall
|
|
259
|
+
auth: { type: "apiKey", key: "pcft_live_..." }
|
|
260
|
+
| { type: "bearer", token: "eyJ..." }
|
|
261
|
+
| { type: "cookie", value: "auth_token=..." },
|
|
262
|
+
// Override the prod base URL — useful for dev / staging
|
|
263
|
+
baseUrl: "https://api.heimdall.example.test",
|
|
264
|
+
// Custom fetch (undici with retry, mock in tests, ...)
|
|
265
|
+
fetch: customFetch,
|
|
266
|
+
// Expected JWT `aud` claim for `consumer(slug).verifyToken(...)`. Optional.
|
|
267
|
+
expectedAudience: "my-app",
|
|
268
|
+
// JWKS cache TTL — default 10 minutes
|
|
269
|
+
jwksTtlMs: 10 * 60 * 1000,
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## How this SDK is built
|
|
274
|
+
|
|
275
|
+
Generated from the live OpenAPI spec at `https://api.heimdall.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.
|
|
276
|
+
|
|
277
|
+
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.
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
[MIT](../../LICENSE).
|