@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 +33 -0
- package/README.md +44 -6
- package/dist/server.d.ts +121 -38
- package/dist/server.js +61 -6
- package/dist/web-app.d.ts +96 -0
- package/dist/web-app.js +138 -0
- package/package.json +5 -3
- package/src/index.ts +325 -0
- package/src/server.ts +918 -0
- package/src/util.ts +121 -0
- package/src/web-app.ts +207 -0
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: "…",
|
|
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("
|
|
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(
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
165
|
+
user_id: string;
|
|
166
|
+
auth_time: string;
|
|
116
167
|
amr: string[];
|
|
117
168
|
ip: string;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
198
|
+
email_verified?: boolean;
|
|
130
199
|
/** E.164, e.g. +447700900000. */
|
|
131
|
-
|
|
132
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
157
|
-
export type NewUser = Omit<ImportUser, "id" | "identities" | "
|
|
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
|
-
|
|
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" | "
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
295
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
230
|
-
match = (c) => c.sub ===
|
|
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.
|
|
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
|
+
}
|