@9in-io/sdk 0.1.1 → 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - `WebAppAuth` (`@9in-io/sdk/server`): sign-in for server-rendered apps (authorization code +
6
+ PKCE, with or without a client secret), using only `fetch` and Web Crypto, so it runs on Node,
7
+ Next.js Edge, Cloudflare Workers, Deno and Bun. `createLogin`, `handleCallback` (state, RFC 9207
8
+ issuer check and ID token verification), `refresh`, `logoutUrl`, `revoke`, `userInfo`. It's the
9
+ base of `@9in-io/nextjs`; you keep the transaction and session where you like.
10
+ - `verifyIdToken`: signature, issuer, audience (`azp` with several), expiry and nonce.
11
+
12
+ ## 0.2.0
13
+
14
+ Breaking: 9in's wire formats are now snake_case throughout, with ISO 8601 UTC times
15
+ (see "API conventions" in the docs). Needs the 9in release of 2026-11-10.
16
+
17
+ - `WebhookEvent`: `type` → `event_type`, `createdAt` → `created_at`; `data.userId` is gone, use
18
+ `data.user.id`. For `user.deleted`, `data.user` is the user as it was before the deletion.
19
+ `token.revoked` data has `token_type` and `client_id`.
20
+ - `ManagedUser`: `created_at`, `updated_at`, `last_login_at` (ISO strings), `login_count`,
21
+ and every profile field.
22
+ - `ManagedSession`: `user_id`, `auth_time`, `user_agent`, `created_at`, `last_seen_at`,
23
+ `expires_at` (ISO strings); `revokedAt` removed.
24
+ - `getUser` returns `ManagedUserDetail` (`user`, `identities`, `sessions`, `has_password`,
25
+ `mfa`); `listUsers` returns `next_cursor`.
26
+ - `NewUser` / `ImportUser`: `email_verified`, `phone_number`, `phone_number_verified`,
27
+ `password_hash`, and ISO `created_at` / `last_login_at`. The server refuses the old names.
28
+ - `findUser("phone_number", …)` replaces `findUser("phone", …)`.
29
+ - `createLoginLink` returns `expires_at` as an ISO string.
30
+ - `AdapterEvent`: `event_type`, `created_at`, and `data.user_id` / `data.expires_at`.
31
+ - `TokenVerifier.handleEvent` reads the new webhook shape.
32
+ - The package ships `src/` too, so the `@9in/source` export condition resolves (0.1.x listed
33
+ it without shipping the files, which broke bundlers configured with that condition).
package/README.md CHANGED
@@ -30,6 +30,34 @@ const res = await auth.fetch("https://api.acme.com/orders");
30
30
  await auth.logout({ returnTo: location.origin });
31
31
  ```
32
32
 
33
+ ## Sign users in (server-rendered apps)
34
+
35
+ `WebAppAuth` runs the sign-in on your server: Node, Next.js (also Edge), Cloudflare Workers,
36
+ Deno and Bun. You keep the transaction (between the redirect and the callback) and the session,
37
+ for example in encrypted cookies. With Next.js, `@9in-io/nextjs` does all of that for you.
38
+
39
+ ```ts
40
+ import { WebAppAuth } from "@9in-io/sdk/server";
41
+
42
+ const auth = new WebAppAuth({
43
+ issuer: "https://acme.9in.io",
44
+ clientId: process.env.NINE_CLIENT_ID!,
45
+ clientSecret: process.env.NINE_CLIENT_SECRET, // confidential apps; leave out for public ones
46
+ redirectUri: "https://app.acme.com/auth/callback",
47
+ });
48
+
49
+ // /auth/login
50
+ const { url, transaction } = await auth.createLogin({ returnTo: "/dashboard" });
51
+ // store `transaction` (encrypted, ~15 minutes), then redirect to `url`
52
+
53
+ // /auth/callback
54
+ const { tokens, claims, returnTo } = await auth.handleCallback(request.url, transaction);
55
+ // claims: the verified ID token (sub, email, name, amr…); tokens: access, refresh, ID token
56
+
57
+ const fresh = await auth.refresh(tokens.refreshToken!); // 9in rotates refresh tokens: keep the new one
58
+ const bye = await auth.logoutUrl({ idTokenHint: tokens.idToken, postLogoutRedirectUri: "https://app.acme.com/" });
59
+ ```
60
+
33
61
  ## Protect your API (server)
34
62
 
35
63
  ```ts
@@ -88,9 +116,10 @@ webhook events, or a short `cacheTtl`.
88
116
  import { ManagementClient } from "@9in-io/sdk/server";
89
117
 
90
118
  const nine = new ManagementClient({ issuer, clientId, clientSecret }); // app with management scopes
91
- await nine.createUser({ email: "jane@acme.com", password: "…", emailVerified: true });
119
+ await nine.createUser({ email: "jane@acme.com", password: "…", email_verified: true });
120
+ await nine.updateUser(userId, { phone_number: "+447700900000", phone_number_verified: true });
92
121
  await nine.blockUser(userId);
93
- const { user } = await nine.findUser("phone", "+447700900000"); // or "email", "username"
122
+ const { user } = await nine.findUser("phone_number", "+447700900000"); // or "email", "username"
94
123
 
95
124
  // Sessions: list them, end one (its sid), or sign the user out everywhere
96
125
  const { sessions } = await nine.listUserSessions(userId);
@@ -101,9 +130,16 @@ await nine.revokeUserSessions(userId);
101
130
  const { url } = await nine.createLoginLink(userId, { returnTo: "https://app.acme.com/callback", ttlSeconds: 600 });
102
131
 
103
132
  // Bulk import (500 per call): old ids, password hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities
104
- const { results } = await nine.importUsers([{ id: "auth0|42", email: "jane@acme.com", passwordHash: "$2b$10$…" }], { onConflict: "skip", source: "auth0" });
133
+ const { results } = await nine.importUsers(
134
+ [{ id: "auth0|42", email: "jane@acme.com", password_hash: "$2b$10$…", created_at: "2021-03-04T05:06:07Z" }],
135
+ { onConflict: "skip", source: "auth0" },
136
+ );
105
137
  ```
106
138
 
139
+ Users, sessions and requests use snake_case field names, the same going in and coming out
140
+ (`email_verified`, `phone_number`, `password_hash`), and times are ISO 8601 UTC strings
141
+ (`created_at`, `last_login_at`). Method options stay camelCase (`returnTo`, `onConflict`).
142
+
107
143
  ## Keep users in your own database (adapter)
108
144
 
109
145
  ```ts
@@ -123,7 +159,8 @@ export default {
123
159
  return user ? toAdapterUser(user) : null;
124
160
  },
125
161
  async onEvent(event) {
126
- if (event.type === "otp.requested") await sendCode(event.data);
162
+ // { id, event_type, created_at, data }; data.user_id is your own user id
163
+ if (event.event_type === "otp.requested") await sendCode(event.data); // user_id, channel, to, code, purpose, expires_at
127
164
  },
128
165
  }),
129
166
  };
@@ -144,8 +181,9 @@ import { verifyWebhook } from "@9in-io/sdk/server";
144
181
  export default {
145
182
  async fetch(request: Request, env: { NINE_WEBHOOK_SECRET: string }) {
146
183
  const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
147
- // event.id is the same on every retry and redelivery: ignore ones you have seen.
148
- if (event.type === "user.created") await addToCrm(event.data.user);
184
+ // { id, event_type, created_at, tenant, environment, data }. event.id is the same on every
185
+ // retry and redelivery: ignore ones you have seen. data.user is the user the event is about.
186
+ if (event.event_type === "user.created") await addToCrm(event.data.user);
149
187
  return new Response(null, { status: 204 });
150
188
  },
151
189
  };
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { NineError, normalizeIssuer } from "./util.js";
2
+ export { WebAppAuth, type WebAppAuthOptions, type WebLoginOptions, type LoginTransaction, type WebTokenSet, type CallbackResult } from "./web-app.js";
2
3
  export interface VerifyOptions {
3
4
  /** Your tenant's issuer (exactly as in the token's iss claim). */
4
5
  issuer: string;
@@ -39,6 +40,43 @@ export interface AccessTokenClaims {
39
40
  * `createTokenVerifier` (hybrid mode) when revocation must take effect at once.
40
41
  */
41
42
  export declare function verifyAccessToken(token: string | null | undefined, options: VerifyOptions): Promise<AccessTokenClaims>;
43
+ export interface VerifyIdTokenOptions {
44
+ /** Your tenant's issuer URL. */
45
+ issuer: string;
46
+ /** Your application's client ID (the token's audience). */
47
+ clientId: string;
48
+ /** The nonce sent with the sign-in request. */
49
+ nonce?: string | null;
50
+ /** For tenants whose signing key is a shared (HMAC) secret. */
51
+ secret?: string;
52
+ /** Seconds of clock skew allowed (default 30). */
53
+ clockTolerance?: number;
54
+ fetch?: typeof fetch;
55
+ allowInsecureIssuer?: boolean;
56
+ }
57
+ /** Claims of a verified ID token (standard OIDC claims plus your custom profile claims). */
58
+ export interface IdTokenClaims {
59
+ iss: string;
60
+ sub: string;
61
+ aud: string | string[];
62
+ exp: number;
63
+ iat: number;
64
+ nonce?: string;
65
+ auth_time?: number;
66
+ /** How the user signed in, e.g. ["pwd", "mfa", "otp"]. */
67
+ amr?: string[];
68
+ sid?: string;
69
+ email?: string;
70
+ email_verified?: boolean;
71
+ name?: string;
72
+ picture?: string;
73
+ [claim: string]: unknown;
74
+ }
75
+ /**
76
+ * Verifies an ID token from your tenant: signature (JWKS, or the shared secret for HMAC keys),
77
+ * issuer, audience (your client ID; `azp` when there are several), expiry and nonce.
78
+ */
79
+ export declare function verifyIdToken(token: string | null | undefined, options: VerifyIdTokenOptions): Promise<IdTokenClaims>;
42
80
  export type VerificationMode = "local" | "hybrid" | "introspect";
43
81
  export interface TokenVerifierOptions extends Omit<VerifyOptions, "scope"> {
44
82
  /**
@@ -69,7 +107,7 @@ export interface TokenVerifier {
69
107
  * user.sessions_revoked, user.updated and user.deleted).
70
108
  */
71
109
  handleEvent(event: {
72
- type: string;
110
+ event_type: string;
73
111
  data: Record<string, unknown>;
74
112
  }): number;
75
113
  /** Forgets every cached answer. */
@@ -94,42 +132,73 @@ export interface ManagementOptions {
94
132
  allowInsecureIssuer?: boolean;
95
133
  fetch?: typeof fetch;
96
134
  }
135
+ /** A user as the Management API and webhooks send it. Times are ISO 8601 UTC strings. */
97
136
  export interface ManagedUser {
98
137
  id: string;
99
138
  email: string | null;
100
139
  email_verified: boolean;
101
140
  phone_number: string | null;
141
+ phone_number_verified: boolean;
102
142
  username: string | null;
143
+ preferred_username: string | null;
103
144
  name: string | null;
145
+ given_name: string | null;
146
+ middle_name: string | null;
147
+ family_name: string | null;
148
+ nickname: string | null;
149
+ gender: string | null;
150
+ website: string | null;
104
151
  picture: string | null;
152
+ locale: string | null;
153
+ zoneinfo: string | null;
154
+ birthdate: string | null;
105
155
  blocked: boolean;
106
156
  metadata: Record<string, unknown>;
107
- createdAt: number;
108
- lastLoginAt: number | null;
109
- [field: string]: unknown;
157
+ created_at: string;
158
+ updated_at: string;
159
+ last_login_at: string | null;
160
+ login_count: number;
110
161
  }
111
- /** An active sign-in session (`sid` is the `sid` claim of its tokens). Times are ms since epoch. */
162
+ /** An active sign-in session (`sid` is the `sid` claim of its tokens). Times are ISO 8601 UTC strings. */
112
163
  export interface ManagedSession {
113
164
  sid: string;
114
- userId: string;
115
- authTime: number;
165
+ user_id: string;
166
+ auth_time: string;
116
167
  amr: string[];
117
168
  ip: string;
118
- userAgent: string;
119
- createdAt: number;
120
- lastSeenAt: number;
121
- expiresAt: number;
122
- revokedAt: number | null;
169
+ user_agent: string;
170
+ created_at: string;
171
+ last_seen_at: string;
172
+ expires_at: string;
173
+ }
174
+ /** A linked sign-in of a user (`social:google`, `enterprise:<id>`, `adapter`, …). */
175
+ export interface ManagedIdentity {
176
+ connection: string;
177
+ subject: string;
178
+ profile: Record<string, unknown>;
179
+ created_at: string;
180
+ }
181
+ /** `getUser`: the user with their identities, active sessions and two-factor state. */
182
+ export interface ManagedUserDetail {
183
+ user: ManagedUser;
184
+ identities: ManagedIdentity[];
185
+ sessions: ManagedSession[];
186
+ has_password: boolean;
187
+ mfa: {
188
+ enabled: boolean;
189
+ enabled_at?: string;
190
+ recovery_codes_left?: number;
191
+ };
123
192
  }
124
193
  /** One record for `importUsers`: the fields of `createUser` plus what an import may carry over. */
125
194
  export interface ImportUser {
126
195
  /** Keep the id from your old system (letters, digits and | _ . : @ + -, max 128). */
127
196
  id?: string;
128
197
  email?: string | null;
129
- emailVerified?: boolean;
198
+ email_verified?: boolean;
130
199
  /** E.164, e.g. +447700900000. */
131
- phone?: string | null;
132
- phoneVerified?: boolean;
200
+ phone_number?: string | null;
201
+ phone_number_verified?: boolean;
133
202
  username?: string | null;
134
203
  name?: string | null;
135
204
  given_name?: string | null;
@@ -140,21 +209,21 @@ export interface ImportUser {
140
209
  birthdate?: string | null;
141
210
  metadata?: Record<string, unknown>;
142
211
  blocked?: boolean;
143
- /** A plain password (at most 50 such records per call), or `passwordHash` from your old system. */
212
+ /** A plain password (at most 50 such records per call), or `password_hash` from your old system. */
144
213
  password?: string;
145
214
  /** bcrypt, argon2, PBKDF2 or Firebase scrypt hash: users keep their password. */
146
- passwordHash?: string;
215
+ password_hash?: string;
147
216
  /** Linked sign-ins, e.g. { connection: "social:google", subject: "1069…" }. */
148
217
  identities?: {
149
218
  connection: string;
150
219
  subject: string;
151
220
  }[];
152
- /** ms since epoch */
153
- createdAt?: number;
154
- lastLoginAt?: number;
221
+ /** Original dates, ISO 8601 (`Z` or an offset). */
222
+ created_at?: string;
223
+ last_login_at?: string;
155
224
  }
156
- /** Fields of a new user (`createUser`, `updateUser`). */
157
- export type NewUser = Omit<ImportUser, "id" | "identities" | "createdAt" | "lastLoginAt">;
225
+ /** Fields of a new user (`createUser`); `updateUser` takes any of them. */
226
+ export type NewUser = Omit<ImportUser, "id" | "identities" | "created_at" | "last_login_at">;
158
227
  export type ImportResult = {
159
228
  index: number;
160
229
  ok: boolean;
@@ -185,16 +254,16 @@ export declare class ManagementClient {
185
254
  limit?: number;
186
255
  }): Promise<{
187
256
  users: ManagedUser[];
188
- nextCursor: string | null;
189
- }>;
190
- getUser(id: string): Promise<{
191
- user: ManagedUser;
257
+ next_cursor: string | null;
258
+ total: number;
192
259
  }>;
260
+ /** The user with their identities, active sessions and two-factor state. */
261
+ getUser(id: string): Promise<ManagedUserDetail>;
193
262
  findUserByEmail(email: string): Promise<{
194
263
  user: ManagedUser;
195
264
  }>;
196
- /** Looks a user up by email, phone (E.164) or username; throws `not_found` (404) when unknown. */
197
- findUser(type: "email" | "phone" | "username", value: string): Promise<{
265
+ /** Looks a user up by email, phone number (E.164) or username; throws `not_found` (404) when unknown. */
266
+ findUser(type: "email" | "phone_number" | "username", value: string): Promise<{
198
267
  user: ManagedUser;
199
268
  }>;
200
269
  createUser(input: NewUser): Promise<{
@@ -211,7 +280,8 @@ export declare class ManagementClient {
211
280
  }): Promise<{
212
281
  results: ImportResult[];
213
282
  }>;
214
- updateUser(id: string, input: Record<string, unknown>): Promise<{
283
+ /** Changes only the fields you send; `metadata` is replaced as a whole. */
284
+ updateUser(id: string, input: Partial<NewUser>): Promise<{
215
285
  user: ManagedUser;
216
286
  }>;
217
287
  deleteUser(id: string): Promise<void>;
@@ -228,7 +298,7 @@ export declare class ManagementClient {
228
298
  ttlSeconds?: number;
229
299
  }): Promise<{
230
300
  url: string;
231
- expires_at: number;
301
+ expires_at: string;
232
302
  }>;
233
303
  /** The user's active sessions, most recently used first. */
234
304
  listUserSessions(id: string): Promise<{
@@ -262,11 +332,20 @@ export interface AdapterUser {
262
332
  blocked?: boolean | null;
263
333
  metadata?: Record<string, unknown> | null;
264
334
  }
335
+ /**
336
+ * What 9in POSTs to `…/event`. `data.user_id` is your own user id. `otp.requested` carries
337
+ * `user_id, channel, to, code, purpose, expires_at`; `user.updated` and
338
+ * `user.password_changed` carry `user_id, fields`.
339
+ */
265
340
  export interface AdapterEvent {
266
341
  id: string;
267
- type: "otp.requested" | "user.created" | "user.updated" | "user.password_changed" | "session.revoked";
268
- createdAt: string;
269
- data: Record<string, unknown>;
342
+ event_type: "otp.requested" | "user.created" | "user.updated" | "user.password_changed" | "session.revoked";
343
+ /** ISO 8601 UTC. */
344
+ created_at: string;
345
+ data: {
346
+ user_id?: string;
347
+ [field: string]: unknown;
348
+ };
270
349
  }
271
350
  export interface AdapterHandlers {
272
351
  /** Shared secret from the console (Connections → User database). Several allow rotation. */
@@ -291,14 +370,18 @@ export type WebhookEventType = "user.created" | "user.updated" | "user.deleted"
291
370
  export interface WebhookEvent<Data extends Record<string, unknown> = Record<string, unknown>> {
292
371
  /** Event id: the same on every retry and redelivery, so you can ignore duplicates. */
293
372
  id: string;
294
- type: WebhookEventType;
295
- createdAt: string;
373
+ /** The event name, also in the `9in-event-type` header. */
374
+ event_type: WebhookEventType;
375
+ /** ISO 8601 UTC. */
376
+ created_at: string;
296
377
  /** Tenant id (tnt_…). */
297
378
  tenant: string;
298
379
  environment: "production" | "sandbox";
299
- /** Event details, plus `userId` and a `user` snapshot (null once deleted) for user events. */
380
+ /**
381
+ * Event details, plus the `user` the event is about (null when there is none; for
382
+ * `user.deleted`, the user as it was just before the deletion).
383
+ */
300
384
  data: Data & {
301
- userId?: string | null;
302
385
  user?: ManagedUser | null;
303
386
  };
304
387
  }
@@ -314,7 +397,7 @@ export interface VerifyWebhookOptions {
314
397
  * the body, so pass `request.clone()` if you need it afterwards.
315
398
  *
316
399
  * const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
317
- * if (event.type === "user.created") …
400
+ * if (event.event_type === "user.created") …
318
401
  */
319
402
  export declare function verifyWebhook(secret: string | string[], request: Request, options?: VerifyWebhookOptions): Promise<WebhookEvent>;
320
403
  /**
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NineError, decodeJwt, discover, normalizeIssuer, postForm, toHex, utf8 } from "./util.js";
2
2
  export { NineError, normalizeIssuer } from "./util.js";
3
+ export { WebAppAuth } from "./web-app.js";
3
4
  /** WebCrypto parameters for each JWS algorithm 9in signs with. */
4
5
  const ALGORITHMS = {
5
6
  RS256: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: "RSASSA-PKCS1-v1_5" },
@@ -111,6 +112,57 @@ export async function verifyAccessToken(token, options) {
111
112
  checkClaims(payload, issuer, options);
112
113
  return payload;
113
114
  }
115
+ /**
116
+ * Verifies an ID token from your tenant: signature (JWKS, or the shared secret for HMAC keys),
117
+ * issuer, audience (your client ID; `azp` when there are several), expiry and nonce.
118
+ */
119
+ export async function verifyIdToken(token, options) {
120
+ if (!token)
121
+ throw new NineError("invalid_token", "missing ID token", 401);
122
+ const fetcher = options.fetch ?? fetch.bind(globalThis);
123
+ const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
124
+ let parts;
125
+ try {
126
+ parts = decodeJwt(token);
127
+ }
128
+ catch {
129
+ throw new NineError("invalid_token", "malformed ID token", 401);
130
+ }
131
+ const { header, payload, signingInput, signature } = parts;
132
+ const alg = String(header.alg);
133
+ // An access token is never accepted as an ID token.
134
+ if (ACCESS_TOKEN_TYPES.has(String(header.typ ?? "").toLowerCase()))
135
+ throw new NineError("invalid_token", "not an ID token", 401);
136
+ let valid;
137
+ if (HMAC_HASH[alg]) {
138
+ if (!options.secret)
139
+ throw new NineError("invalid_token", "HMAC-signed ID token: pass the tenant's shared secret", 401);
140
+ const key = await crypto.subtle.importKey("raw", utf8(options.secret), { name: "HMAC", hash: HMAC_HASH[alg] }, false, ["verify"]);
141
+ valid = await crypto.subtle.verify("HMAC", key, signature, utf8(signingInput));
142
+ }
143
+ else if (ALGORITHMS[alg]) {
144
+ const jwk = await findKey(issuer, header.kid, fetcher);
145
+ if (!jwk)
146
+ throw new NineError("invalid_token", "unknown signing key", 401);
147
+ if (jwk.alg && jwk.alg !== alg)
148
+ throw new NineError("invalid_token", "algorithm does not match the key", 401);
149
+ valid = await crypto.subtle.verify(ALGORITHMS[alg].verify, await importPublicKey(jwk, alg), signature, utf8(signingInput));
150
+ }
151
+ else {
152
+ throw new NineError("invalid_token", "unsupported algorithm", 401);
153
+ }
154
+ if (!valid)
155
+ throw new NineError("invalid_token", "bad signature", 401);
156
+ checkClaims(payload, issuer, { issuer, audience: options.clientId, clockTolerance: options.clockTolerance });
157
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
158
+ if (aud.length > 1 && payload.azp !== options.clientId)
159
+ throw new NineError("invalid_token", "authorized party mismatch", 401);
160
+ if (options.nonce !== undefined && options.nonce !== null && payload.nonce !== options.nonce)
161
+ throw new NineError("invalid_token", "nonce mismatch", 401);
162
+ if (typeof payload.sub !== "string" || !payload.sub)
163
+ throw new NineError("invalid_token", "missing subject", 401);
164
+ return payload;
165
+ }
114
166
  function checkClaims(payload, issuer, options) {
115
167
  const now = Math.floor(Date.now() / 1000);
116
168
  const skew = options.clockTolerance ?? 30;
@@ -221,13 +273,14 @@ export function createTokenVerifier(options) {
221
273
  },
222
274
  handleEvent(event) {
223
275
  const data = event.data ?? {};
276
+ const userId = data.user?.id;
224
277
  let match = null;
225
- if (event.type === "token.revoked" && data.type === "access_token" && data.jti)
278
+ if (event.event_type === "token.revoked" && data.token_type === "access_token" && data.jti)
226
279
  match = (c) => c.jti === data.jti;
227
- else if (event.type === "session.revoked" && data.sid)
280
+ else if (event.event_type === "session.revoked" && data.sid)
228
281
  match = (c) => c.sid === data.sid;
229
- else if (["user.sessions_revoked", "user.deleted", "user.updated", "user.password_changed"].includes(event.type) && data.userId) {
230
- match = (c) => c.sub === data.userId;
282
+ else if (["user.sessions_revoked", "user.deleted", "user.updated", "user.password_changed"].includes(event.event_type) && typeof userId === "string") {
283
+ match = (c) => c.sub === userId;
231
284
  }
232
285
  if (!match)
233
286
  return 0;
@@ -289,13 +342,14 @@ export class ManagementClient {
289
342
  params.set("limit", String(options.limit));
290
343
  return this.request("GET", `/users?${params}`);
291
344
  }
345
+ /** The user with their identities, active sessions and two-factor state. */
292
346
  getUser(id) {
293
347
  return this.request("GET", `/users/${encodeURIComponent(id)}`);
294
348
  }
295
349
  findUserByEmail(email) {
296
350
  return this.findUser("email", email);
297
351
  }
298
- /** Looks a user up by email, phone (E.164) or username; throws `not_found` (404) when unknown. */
352
+ /** Looks a user up by email, phone number (E.164) or username; throws `not_found` (404) when unknown. */
299
353
  findUser(type, value) {
300
354
  return this.request("GET", `/users/lookup?${type}=${encodeURIComponent(value)}`);
301
355
  }
@@ -314,6 +368,7 @@ export class ManagementClient {
314
368
  ...(options.source ? { source: options.source } : {}),
315
369
  });
316
370
  }
371
+ /** Changes only the fields you send; `metadata` is replaced as a whole. */
317
372
  updateUser(id, input) {
318
373
  return this.request("PATCH", `/users/${encodeURIComponent(id)}`, input);
319
374
  }
@@ -386,7 +441,7 @@ export async function signPayload(secret, method, path, body) {
386
441
  * the body, so pass `request.clone()` if you need it afterwards.
387
442
  *
388
443
  * const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
389
- * if (event.type === "user.created") …
444
+ * if (event.event_type === "user.created") …
390
445
  */
391
446
  export async function verifyWebhook(secret, request, options = {}) {
392
447
  const body = await request.text();
@@ -0,0 +1,96 @@
1
+ import { type IdTokenClaims } from "./server.js";
2
+ /**
3
+ * Sign-in for server-rendered web apps (authorization code + PKCE, with or without a client
4
+ * secret), using only `fetch` and Web Crypto: it runs on Node 18+, Next.js (Node and Edge),
5
+ * Cloudflare Workers, Deno and Bun. Framework packages keep the transaction and the session
6
+ * (for example in encrypted cookies); this class does the protocol:
7
+ *
8
+ * const { url, transaction } = await auth.createLogin({ returnTo: "/dashboard" });
9
+ * // keep `transaction` until the callback, redirect the browser to `url`
10
+ * const { tokens, claims, returnTo } = await auth.handleCallback(request.url, transaction);
11
+ */
12
+ export interface WebAppAuthOptions {
13
+ /** Your tenant's issuer URL, e.g. https://acme.9in.io. */
14
+ issuer: string;
15
+ clientId: string;
16
+ /** For confidential applications: sent with HTTP Basic. Leave out for public ones (PKCE only). */
17
+ clientSecret?: string;
18
+ /** Your callback URL, as registered on the application. */
19
+ redirectUri: string;
20
+ /** Default "openid profile email offline_access" (offline_access gives a refresh token). */
21
+ scope?: string;
22
+ /** An API identifier, to get access tokens for that API. */
23
+ audience?: string;
24
+ /** Only for tenants that sign with a shared (HMAC) key. */
25
+ idTokenSecret?: string;
26
+ fetch?: typeof fetch;
27
+ /** Allows http:// issuers other than localhost (never in production). */
28
+ allowInsecureIssuer?: boolean;
29
+ }
30
+ export interface WebLoginOptions {
31
+ /** A path in your app to come back to after sign-in. */
32
+ returnTo?: string;
33
+ prompt?: "login" | "consent" | "none" | "select_account" | "create";
34
+ /** Prefills the identifier on the sign-in page. */
35
+ loginHint?: string;
36
+ /** "signup" opens the registration page. */
37
+ screen?: "login" | "signup";
38
+ /** Language of the hosted pages (ui_locales), e.g. "de". */
39
+ locale?: string;
40
+ /** Extra authorization parameters (they never replace state, nonce or PKCE). */
41
+ params?: Record<string, string>;
42
+ }
43
+ /** What to keep between the redirect to 9in and the callback (store it encrypted; it's secret). */
44
+ export interface LoginTransaction {
45
+ state: string;
46
+ nonce: string;
47
+ verifier: string;
48
+ returnTo?: string;
49
+ /** ms */
50
+ createdAt: number;
51
+ }
52
+ export interface WebTokenSet {
53
+ accessToken: string;
54
+ idToken: string | null;
55
+ refreshToken: string | null;
56
+ /** ms */
57
+ expiresAt: number;
58
+ scope: string;
59
+ tokenType: string;
60
+ }
61
+ export interface CallbackResult {
62
+ tokens: WebTokenSet;
63
+ /** The verified ID token's claims: the signed-in user. */
64
+ claims: IdTokenClaims;
65
+ returnTo?: string;
66
+ }
67
+ export declare class WebAppAuth {
68
+ private readonly options;
69
+ private readonly issuer;
70
+ private readonly fetcher;
71
+ constructor(options: WebAppAuthOptions);
72
+ /** The URL to send the browser to, and the transaction to keep until the callback. */
73
+ createLogin(options?: WebLoginOptions): Promise<{
74
+ url: string;
75
+ transaction: LoginTransaction;
76
+ }>;
77
+ /**
78
+ * Finishes sign-in on your callback URL: checks the state against the transaction, exchanges
79
+ * the code and verifies the ID token (signature, issuer, audience, expiry, nonce).
80
+ */
81
+ handleCallback(callbackUrl: string | URL, transaction: LoginTransaction | null | undefined): Promise<CallbackResult>;
82
+ /** New tokens from a refresh token (9in rotates it: keep the one returned). */
83
+ refresh(refreshToken: string): Promise<WebTokenSet>;
84
+ /** The URL that ends the session at 9in and comes back to `postLogoutRedirectUri`. */
85
+ logoutUrl(options?: {
86
+ idTokenHint?: string | null;
87
+ postLogoutRedirectUri?: string;
88
+ state?: string;
89
+ }): Promise<string>;
90
+ /** Revokes a refresh token (ending the session's token family) or an access token. */
91
+ revoke(token: string, hint?: "refresh_token" | "access_token"): Promise<void>;
92
+ /** The user's current profile from the userinfo endpoint. */
93
+ userInfo(accessToken: string): Promise<Record<string, unknown>>;
94
+ private clientAuth;
95
+ private tokenRequest;
96
+ }