@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/src/server.ts
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { NineError, decodeJwt, discover, normalizeIssuer, postForm, toHex, utf8 } from "./util.js";
|
|
2
|
+
|
|
3
|
+
export { NineError, normalizeIssuer } from "./util.js";
|
|
4
|
+
export { WebAppAuth, type WebAppAuthOptions, type WebLoginOptions, type LoginTransaction, type WebTokenSet, type CallbackResult } from "./web-app.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Access token verification
|
|
8
|
+
|
|
9
|
+
export interface VerifyOptions {
|
|
10
|
+
/** Your tenant's issuer (exactly as in the token's iss claim). */
|
|
11
|
+
issuer: string;
|
|
12
|
+
/** API identifier the token must be issued for (aud). */
|
|
13
|
+
audience?: string | string[];
|
|
14
|
+
/** Scopes that must all be present (space-separated or array). */
|
|
15
|
+
scope?: string | string[];
|
|
16
|
+
/**
|
|
17
|
+
* Shared secret of the HMAC key (HS256/384/512) your API signs with, from the console's
|
|
18
|
+
* key library. Only needed when the API uses an HMAC key; JWKS keys need nothing.
|
|
19
|
+
*/
|
|
20
|
+
secret?: string;
|
|
21
|
+
/** Allowed clock skew in seconds (default 30). */
|
|
22
|
+
clockTolerance?: number;
|
|
23
|
+
/** Accept a plain-http issuer other than localhost (development only). */
|
|
24
|
+
allowInsecureIssuer?: boolean;
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AccessTokenClaims {
|
|
29
|
+
iss: string;
|
|
30
|
+
sub: string;
|
|
31
|
+
aud: string | string[];
|
|
32
|
+
exp: number;
|
|
33
|
+
iat: number;
|
|
34
|
+
client_id: string;
|
|
35
|
+
scope: string;
|
|
36
|
+
jti?: string;
|
|
37
|
+
sid?: string;
|
|
38
|
+
auth_time?: number;
|
|
39
|
+
[claim: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Jwk extends JsonWebKey {
|
|
43
|
+
kid?: string;
|
|
44
|
+
alg?: string;
|
|
45
|
+
crv?: string;
|
|
46
|
+
x?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Derived from SubtleCrypto itself so the SDK type-checks under DOM, Node and Workers typings.
|
|
50
|
+
type SubtleParams = { import: Parameters<SubtleCrypto["importKey"]>[2]; verify: Parameters<SubtleCrypto["verify"]>[0] };
|
|
51
|
+
|
|
52
|
+
/** WebCrypto parameters for each JWS algorithm 9in signs with. */
|
|
53
|
+
const ALGORITHMS: Record<string, SubtleParams> = {
|
|
54
|
+
RS256: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: "RSASSA-PKCS1-v1_5" },
|
|
55
|
+
RS384: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: "RSASSA-PKCS1-v1_5" },
|
|
56
|
+
RS512: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: "RSASSA-PKCS1-v1_5" },
|
|
57
|
+
PS256: { import: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 } },
|
|
58
|
+
PS384: { import: { name: "RSA-PSS", hash: "SHA-384" }, verify: { name: "RSA-PSS", saltLength: 48 } },
|
|
59
|
+
PS512: { import: { name: "RSA-PSS", hash: "SHA-512" }, verify: { name: "RSA-PSS", saltLength: 64 } },
|
|
60
|
+
ES256: { import: { name: "ECDSA", namedCurve: "P-256" }, verify: { name: "ECDSA", hash: "SHA-256" } },
|
|
61
|
+
ES384: { import: { name: "ECDSA", namedCurve: "P-384" }, verify: { name: "ECDSA", hash: "SHA-384" } },
|
|
62
|
+
EdDSA: { import: { name: "Ed25519" }, verify: "Ed25519" },
|
|
63
|
+
};
|
|
64
|
+
const HMAC_HASH: Record<string, string> = { HS256: "SHA-256", HS384: "SHA-384", HS512: "SHA-512" };
|
|
65
|
+
|
|
66
|
+
const jwksCache = new Map<string, { keys: Jwk[]; fetchedAt: number }>();
|
|
67
|
+
const keyCache = new Map<string, CryptoKey>();
|
|
68
|
+
/**
|
|
69
|
+
* kid → time it was last looked up without success. An unknown kid (e.g. right after a key
|
|
70
|
+
* rotation) triggers one JWKS refetch; junk kids are then ignored for a minute.
|
|
71
|
+
*/
|
|
72
|
+
const missingKids = new Map<string, number>();
|
|
73
|
+
|
|
74
|
+
async function jwks(issuer: string, fetcher: typeof fetch, force = false): Promise<Jwk[]> {
|
|
75
|
+
const hit = jwksCache.get(issuer);
|
|
76
|
+
if (hit && !force && Date.now() - hit.fetchedAt < 10 * 60_000) return hit.keys;
|
|
77
|
+
const config = await discover(issuer, fetcher);
|
|
78
|
+
const res = await fetcher(config.jwks_uri);
|
|
79
|
+
if (!res.ok) throw new NineError("jwks_unavailable", `HTTP ${res.status}`, res.status);
|
|
80
|
+
const body = (await res.json()) as { keys: Jwk[] };
|
|
81
|
+
jwksCache.set(issuer, { keys: body.keys, fetchedAt: Date.now() });
|
|
82
|
+
return body.keys;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function findKey(issuer: string, kid: unknown, fetcher: typeof fetch): Promise<Jwk | undefined> {
|
|
86
|
+
const keys = await jwks(issuer, fetcher);
|
|
87
|
+
const known = keys.find((k) => k.kid === kid);
|
|
88
|
+
if (known) return known;
|
|
89
|
+
const missKey = `${issuer}|${String(kid)}`;
|
|
90
|
+
const missedAt = missingKids.get(missKey);
|
|
91
|
+
if (missedAt && Date.now() - missedAt < 60_000) return undefined;
|
|
92
|
+
const refreshed = (await jwks(issuer, fetcher, true)).find((k) => k.kid === kid);
|
|
93
|
+
if (!refreshed) {
|
|
94
|
+
missingKids.set(missKey, Date.now());
|
|
95
|
+
if (missingKids.size > 1000) missingKids.clear();
|
|
96
|
+
}
|
|
97
|
+
return refreshed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function importPublicKey(jwk: Jwk, alg: string): Promise<CryptoKey> {
|
|
101
|
+
const id = `${alg}:${jwk.kid}:${(jwk.n ?? jwk.x ?? "").slice(0, 32)}`;
|
|
102
|
+
let key = keyCache.get(id);
|
|
103
|
+
if (!key) {
|
|
104
|
+
const { kty, n, e, crv, x, y } = jwk;
|
|
105
|
+
const material = kty === "RSA" ? { kty, n, e } : kty === "EC" ? { kty, crv, x, y } : { kty, crv, x };
|
|
106
|
+
key = await crypto.subtle.importKey("jwk", material, ALGORITHMS[alg]!.import, false, ["verify"]);
|
|
107
|
+
if (keyCache.size > 200) keyCache.clear();
|
|
108
|
+
keyCache.set(id, key);
|
|
109
|
+
}
|
|
110
|
+
return key;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ACCESS_TOKEN_TYPES = new Set(["at+jwt", "application/at+jwt"]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Verifies a 9in JWT access token locally: signature (against your tenant's JWKS, or your
|
|
117
|
+
* API's shared secret for HMAC keys), token type, issuer, audience, expiry and required
|
|
118
|
+
* scopes. Keys are cached and refetched once when a new key appears.
|
|
119
|
+
*
|
|
120
|
+
* This is a local check: a token revoked before it expires still passes. Use
|
|
121
|
+
* `createTokenVerifier` (hybrid mode) when revocation must take effect at once.
|
|
122
|
+
*/
|
|
123
|
+
export async function verifyAccessToken(token: string | null | undefined, options: VerifyOptions): Promise<AccessTokenClaims> {
|
|
124
|
+
if (!token) throw new NineError("invalid_token", "missing token", 401);
|
|
125
|
+
const fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
126
|
+
const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
127
|
+
let parts;
|
|
128
|
+
try {
|
|
129
|
+
parts = decodeJwt(token);
|
|
130
|
+
} catch {
|
|
131
|
+
throw new NineError("invalid_token", "malformed token", 401);
|
|
132
|
+
}
|
|
133
|
+
const { header, payload, signingInput, signature } = parts;
|
|
134
|
+
const alg = String(header.alg);
|
|
135
|
+
if (!ACCESS_TOKEN_TYPES.has(String(header.typ).toLowerCase())) throw new NineError("invalid_token", "not an access token", 401);
|
|
136
|
+
let valid: boolean;
|
|
137
|
+
if (HMAC_HASH[alg]) {
|
|
138
|
+
if (!options.secret) throw new NineError("invalid_token", "HMAC-signed token: pass the API's secret", 401);
|
|
139
|
+
const key = await crypto.subtle.importKey("raw", utf8(options.secret), { name: "HMAC", hash: HMAC_HASH[alg] }, false, ["verify"]);
|
|
140
|
+
valid = await crypto.subtle.verify("HMAC", key, signature, utf8(signingInput));
|
|
141
|
+
} else if (ALGORITHMS[alg]) {
|
|
142
|
+
const jwk = await findKey(issuer, header.kid, fetcher);
|
|
143
|
+
if (!jwk) throw new NineError("invalid_token", "unknown signing key", 401);
|
|
144
|
+
// The key decides the algorithm, never the token alone (no algorithm confusion).
|
|
145
|
+
if (jwk.alg && jwk.alg !== alg) throw new NineError("invalid_token", "algorithm does not match the key", 401);
|
|
146
|
+
valid = await crypto.subtle.verify(ALGORITHMS[alg]!.verify, await importPublicKey(jwk, alg), signature, utf8(signingInput));
|
|
147
|
+
} else {
|
|
148
|
+
throw new NineError("invalid_token", "unsupported algorithm", 401);
|
|
149
|
+
}
|
|
150
|
+
if (!valid) throw new NineError("invalid_token", "bad signature", 401);
|
|
151
|
+
checkClaims(payload, issuer, options);
|
|
152
|
+
return payload as AccessTokenClaims;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface VerifyIdTokenOptions {
|
|
156
|
+
/** Your tenant's issuer URL. */
|
|
157
|
+
issuer: string;
|
|
158
|
+
/** Your application's client ID (the token's audience). */
|
|
159
|
+
clientId: string;
|
|
160
|
+
/** The nonce sent with the sign-in request. */
|
|
161
|
+
nonce?: string | null;
|
|
162
|
+
/** For tenants whose signing key is a shared (HMAC) secret. */
|
|
163
|
+
secret?: string;
|
|
164
|
+
/** Seconds of clock skew allowed (default 30). */
|
|
165
|
+
clockTolerance?: number;
|
|
166
|
+
fetch?: typeof fetch;
|
|
167
|
+
allowInsecureIssuer?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Claims of a verified ID token (standard OIDC claims plus your custom profile claims). */
|
|
171
|
+
export interface IdTokenClaims {
|
|
172
|
+
iss: string;
|
|
173
|
+
sub: string;
|
|
174
|
+
aud: string | string[];
|
|
175
|
+
exp: number;
|
|
176
|
+
iat: number;
|
|
177
|
+
nonce?: string;
|
|
178
|
+
auth_time?: number;
|
|
179
|
+
/** How the user signed in, e.g. ["pwd", "mfa", "otp"]. */
|
|
180
|
+
amr?: string[];
|
|
181
|
+
sid?: string;
|
|
182
|
+
email?: string;
|
|
183
|
+
email_verified?: boolean;
|
|
184
|
+
name?: string;
|
|
185
|
+
picture?: string;
|
|
186
|
+
[claim: string]: unknown;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Verifies an ID token from your tenant: signature (JWKS, or the shared secret for HMAC keys),
|
|
191
|
+
* issuer, audience (your client ID; `azp` when there are several), expiry and nonce.
|
|
192
|
+
*/
|
|
193
|
+
export async function verifyIdToken(token: string | null | undefined, options: VerifyIdTokenOptions): Promise<IdTokenClaims> {
|
|
194
|
+
if (!token) throw new NineError("invalid_token", "missing ID token", 401);
|
|
195
|
+
const fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
196
|
+
const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
197
|
+
let parts;
|
|
198
|
+
try {
|
|
199
|
+
parts = decodeJwt(token);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new NineError("invalid_token", "malformed ID token", 401);
|
|
202
|
+
}
|
|
203
|
+
const { header, payload, signingInput, signature } = parts;
|
|
204
|
+
const alg = String(header.alg);
|
|
205
|
+
// An access token is never accepted as an ID token.
|
|
206
|
+
if (ACCESS_TOKEN_TYPES.has(String(header.typ ?? "").toLowerCase())) throw new NineError("invalid_token", "not an ID token", 401);
|
|
207
|
+
let valid: boolean;
|
|
208
|
+
if (HMAC_HASH[alg]) {
|
|
209
|
+
if (!options.secret) throw new NineError("invalid_token", "HMAC-signed ID token: pass the tenant's shared secret", 401);
|
|
210
|
+
const key = await crypto.subtle.importKey("raw", utf8(options.secret), { name: "HMAC", hash: HMAC_HASH[alg] }, false, ["verify"]);
|
|
211
|
+
valid = await crypto.subtle.verify("HMAC", key, signature, utf8(signingInput));
|
|
212
|
+
} else if (ALGORITHMS[alg]) {
|
|
213
|
+
const jwk = await findKey(issuer, header.kid, fetcher);
|
|
214
|
+
if (!jwk) throw new NineError("invalid_token", "unknown signing key", 401);
|
|
215
|
+
if (jwk.alg && jwk.alg !== alg) throw new NineError("invalid_token", "algorithm does not match the key", 401);
|
|
216
|
+
valid = await crypto.subtle.verify(ALGORITHMS[alg]!.verify, await importPublicKey(jwk, alg), signature, utf8(signingInput));
|
|
217
|
+
} else {
|
|
218
|
+
throw new NineError("invalid_token", "unsupported algorithm", 401);
|
|
219
|
+
}
|
|
220
|
+
if (!valid) throw new NineError("invalid_token", "bad signature", 401);
|
|
221
|
+
checkClaims(payload, issuer, { issuer, audience: options.clientId, clockTolerance: options.clockTolerance });
|
|
222
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
223
|
+
if (aud.length > 1 && payload.azp !== options.clientId) throw new NineError("invalid_token", "authorized party mismatch", 401);
|
|
224
|
+
if (options.nonce !== undefined && options.nonce !== null && payload.nonce !== options.nonce) throw new NineError("invalid_token", "nonce mismatch", 401);
|
|
225
|
+
if (typeof payload.sub !== "string" || !payload.sub) throw new NineError("invalid_token", "missing subject", 401);
|
|
226
|
+
return payload as IdTokenClaims;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function checkClaims(payload: Record<string, unknown>, issuer: string, options: VerifyOptions): void {
|
|
230
|
+
const now = Math.floor(Date.now() / 1000);
|
|
231
|
+
const skew = options.clockTolerance ?? 30;
|
|
232
|
+
if (String(payload.iss).replace(/\/$/, "") !== issuer) throw new NineError("invalid_token", "issuer mismatch", 401);
|
|
233
|
+
if (typeof payload.exp !== "number" || payload.exp + skew < now) throw new NineError("invalid_token", "token expired", 401);
|
|
234
|
+
if (typeof payload.nbf === "number" && payload.nbf - skew > now) throw new NineError("invalid_token", "token not yet valid", 401);
|
|
235
|
+
if (options.audience) {
|
|
236
|
+
const wanted = Array.isArray(options.audience) ? options.audience : [options.audience];
|
|
237
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
238
|
+
if (!wanted.some((a) => aud.includes(a))) throw new NineError("invalid_token", "audience mismatch", 401);
|
|
239
|
+
}
|
|
240
|
+
checkScope(payload, options.scope);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function checkScope(payload: Record<string, unknown>, scope: string | string[] | undefined): void {
|
|
244
|
+
if (!scope) return;
|
|
245
|
+
const required = Array.isArray(scope) ? scope : scope.split(" ").filter(Boolean);
|
|
246
|
+
const granted = new Set(String(payload.scope ?? "").split(" "));
|
|
247
|
+
const missing = required.filter((s) => !granted.has(s));
|
|
248
|
+
if (missing.length) throw new NineError("insufficient_scope", `missing ${missing.join(", ")}`, 403);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Hybrid verification (revocation that takes effect at once)
|
|
253
|
+
|
|
254
|
+
export type VerificationMode = "local" | "hybrid" | "introspect";
|
|
255
|
+
|
|
256
|
+
export interface TokenVerifierOptions extends Omit<VerifyOptions, "scope"> {
|
|
257
|
+
/**
|
|
258
|
+
* - `hybrid` (default): the local check, then one introspection per token; the answer is
|
|
259
|
+
* cached and dropped when a `token.revoked`, `session.revoked` or user webhook arrives
|
|
260
|
+
* (pass events to `handleEvent`), or after `cacheTtl` at the latest.
|
|
261
|
+
* - `local`: signature and claims only. Fastest; a revoked token works until it expires.
|
|
262
|
+
* - `introspect`: ask 9in on every call. Always current; one request per call.
|
|
263
|
+
* Opaque tokens (`9in_at_…`) are always introspected (and cached in hybrid mode).
|
|
264
|
+
*/
|
|
265
|
+
mode?: VerificationMode;
|
|
266
|
+
/** A confidential application's credentials, used for introspection. */
|
|
267
|
+
clientId?: string;
|
|
268
|
+
clientSecret?: string;
|
|
269
|
+
/** Longest time a hybrid answer is trusted without hearing from 9in, in seconds (default 300). */
|
|
270
|
+
cacheTtl?: number;
|
|
271
|
+
/** Most tokens kept in the cache (default 10 000; the least recently used go first). */
|
|
272
|
+
cacheSize?: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface CacheEntry {
|
|
276
|
+
claims: AccessTokenClaims | null;
|
|
277
|
+
/** ms */
|
|
278
|
+
until: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface TokenVerifier {
|
|
282
|
+
/** Verifies a bearer token (JWT or opaque) and returns its claims, or throws `NineError`. */
|
|
283
|
+
verify(token: string | null | undefined, options?: { scope?: string | string[] }): Promise<AccessTokenClaims>;
|
|
284
|
+
/**
|
|
285
|
+
* Drops cached answers a webhook event makes stale; returns how many. Pass events from
|
|
286
|
+
* `verifyWebhook` (subscribe the endpoint to token.revoked, session.revoked,
|
|
287
|
+
* user.sessions_revoked, user.updated and user.deleted).
|
|
288
|
+
*/
|
|
289
|
+
handleEvent(event: { event_type: string; data: Record<string, unknown> }): number;
|
|
290
|
+
/** Forgets every cached answer. */
|
|
291
|
+
clear(): void;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const OPAQUE_PREFIX = "9in_at_";
|
|
295
|
+
|
|
296
|
+
async function cacheKey(token: string): Promise<string> {
|
|
297
|
+
return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(token))));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Token verification with a choice of trade-off between speed and revocation (SSOfy's
|
|
302
|
+
* "hybrid" validation, kept): see `mode`.
|
|
303
|
+
*
|
|
304
|
+
* const verifier = createTokenVerifier({ issuer, audience: "https://api.acme.com", clientId, clientSecret });
|
|
305
|
+
* const claims = await verifier.verify(bearer, { scope: "orders:read" });
|
|
306
|
+
*
|
|
307
|
+
* // webhook endpoint (token.revoked, session.revoked, user.* events)
|
|
308
|
+
* verifier.handleEvent(await verifyWebhook(secret, request));
|
|
309
|
+
*/
|
|
310
|
+
export function createTokenVerifier(options: TokenVerifierOptions): TokenVerifier {
|
|
311
|
+
const mode = options.mode ?? "hybrid";
|
|
312
|
+
const fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
313
|
+
const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
314
|
+
const ttl = (options.cacheTtl ?? 300) * 1000;
|
|
315
|
+
const size = options.cacheSize ?? 10_000;
|
|
316
|
+
const cache = new Map<string, CacheEntry>();
|
|
317
|
+
const inflight = new Map<string, Promise<AccessTokenClaims | null>>();
|
|
318
|
+
|
|
319
|
+
if (mode !== "local" && (!options.clientId || !options.clientSecret)) {
|
|
320
|
+
throw new NineError("invalid_options", `${mode} mode needs clientId and clientSecret (a confidential application) for introspection`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function introspect(token: string): Promise<AccessTokenClaims | null> {
|
|
324
|
+
const config = await discover(issuer, fetcher);
|
|
325
|
+
const endpoint = config.introspection_endpoint ?? `${issuer}/oauth/introspect`;
|
|
326
|
+
const body = await postForm<Record<string, unknown>>(
|
|
327
|
+
endpoint,
|
|
328
|
+
{ token },
|
|
329
|
+
{ Authorization: `Basic ${btoa(`${encodeURIComponent(options.clientId!)}:${encodeURIComponent(options.clientSecret!)}`)}` },
|
|
330
|
+
fetcher,
|
|
331
|
+
);
|
|
332
|
+
if (body.active !== true || body.token_type === "refresh_token") return null;
|
|
333
|
+
return body as unknown as AccessTokenClaims;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function remember(key: string, entry: CacheEntry) {
|
|
337
|
+
cache.delete(key);
|
|
338
|
+
cache.set(key, entry);
|
|
339
|
+
while (cache.size > size) cache.delete(cache.keys().next().value!);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The live claims (`local` for a JWT, once 9in confirms it is active), or null when revoked. */
|
|
343
|
+
async function lookup(token: string, local: AccessTokenClaims | null): Promise<AccessTokenClaims | null> {
|
|
344
|
+
const key = await cacheKey(token);
|
|
345
|
+
if (mode === "hybrid") {
|
|
346
|
+
const hit = cache.get(key);
|
|
347
|
+
if (hit && hit.until > Date.now()) {
|
|
348
|
+
remember(key, hit); // most recently used
|
|
349
|
+
return hit.claims;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
let pending = inflight.get(key);
|
|
353
|
+
if (!pending) {
|
|
354
|
+
pending = introspect(token).finally(() => inflight.delete(key));
|
|
355
|
+
inflight.set(key, pending);
|
|
356
|
+
}
|
|
357
|
+
const answer = await pending;
|
|
358
|
+
const claims = answer && local ? local : answer;
|
|
359
|
+
if (mode === "hybrid") {
|
|
360
|
+
const expiry = (claims?.exp ?? local?.exp ?? 0) * 1000;
|
|
361
|
+
// A revoked token never comes back: remember the refusal until it expires.
|
|
362
|
+
const until = claims ? Math.min(Date.now() + ttl, expiry) : expiry ? expiry : Date.now() + 60_000;
|
|
363
|
+
if (until > Date.now()) remember(key, { claims, until });
|
|
364
|
+
}
|
|
365
|
+
return claims;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
async verify(token, extra = {}) {
|
|
370
|
+
if (!token) throw new NineError("invalid_token", "missing token", 401);
|
|
371
|
+
const opaque = token.startsWith(OPAQUE_PREFIX);
|
|
372
|
+
if (opaque && mode === "local") throw new NineError("invalid_token", "opaque tokens need hybrid or introspect mode", 401);
|
|
373
|
+
const local = opaque ? null : await verifyAccessToken(token, { ...options, scope: extra.scope });
|
|
374
|
+
if (mode === "local") return local!;
|
|
375
|
+
const claims = await lookup(token, local);
|
|
376
|
+
if (!claims) throw new NineError("invalid_token", "token revoked", 401);
|
|
377
|
+
if (!local) checkClaims(claims, issuer, { ...options, scope: extra.scope });
|
|
378
|
+
return claims;
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
handleEvent(event) {
|
|
382
|
+
const data = event.data ?? {};
|
|
383
|
+
const userId = (data.user as { id?: unknown } | null | undefined)?.id;
|
|
384
|
+
let match: ((claims: AccessTokenClaims) => boolean) | null = null;
|
|
385
|
+
if (event.event_type === "token.revoked" && data.token_type === "access_token" && data.jti) match = (c) => c.jti === data.jti;
|
|
386
|
+
else if (event.event_type === "session.revoked" && data.sid) match = (c) => c.sid === data.sid;
|
|
387
|
+
else if (["user.sessions_revoked", "user.deleted", "user.updated", "user.password_changed"].includes(event.event_type) && typeof userId === "string") {
|
|
388
|
+
match = (c) => c.sub === userId;
|
|
389
|
+
}
|
|
390
|
+
if (!match) return 0;
|
|
391
|
+
let evicted = 0;
|
|
392
|
+
for (const [key, entry] of cache) {
|
|
393
|
+
if (entry.claims && match(entry.claims)) {
|
|
394
|
+
cache.delete(key);
|
|
395
|
+
evicted++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return evicted;
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
clear() {
|
|
402
|
+
cache.clear();
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Management API
|
|
409
|
+
|
|
410
|
+
export interface ManagementOptions {
|
|
411
|
+
issuer: string;
|
|
412
|
+
clientId: string;
|
|
413
|
+
clientSecret: string;
|
|
414
|
+
/** Accept a plain-http issuer other than localhost (development only). */
|
|
415
|
+
allowInsecureIssuer?: boolean;
|
|
416
|
+
fetch?: typeof fetch;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A user as the Management API and webhooks send it. Times are ISO 8601 UTC strings. */
|
|
420
|
+
export interface ManagedUser {
|
|
421
|
+
id: string;
|
|
422
|
+
email: string | null;
|
|
423
|
+
email_verified: boolean;
|
|
424
|
+
phone_number: string | null;
|
|
425
|
+
phone_number_verified: boolean;
|
|
426
|
+
username: string | null;
|
|
427
|
+
preferred_username: string | null;
|
|
428
|
+
name: string | null;
|
|
429
|
+
given_name: string | null;
|
|
430
|
+
middle_name: string | null;
|
|
431
|
+
family_name: string | null;
|
|
432
|
+
nickname: string | null;
|
|
433
|
+
gender: string | null;
|
|
434
|
+
website: string | null;
|
|
435
|
+
picture: string | null;
|
|
436
|
+
locale: string | null;
|
|
437
|
+
zoneinfo: string | null;
|
|
438
|
+
birthdate: string | null;
|
|
439
|
+
blocked: boolean;
|
|
440
|
+
metadata: Record<string, unknown>;
|
|
441
|
+
created_at: string;
|
|
442
|
+
updated_at: string;
|
|
443
|
+
last_login_at: string | null;
|
|
444
|
+
login_count: number;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** An active sign-in session (`sid` is the `sid` claim of its tokens). Times are ISO 8601 UTC strings. */
|
|
448
|
+
export interface ManagedSession {
|
|
449
|
+
sid: string;
|
|
450
|
+
user_id: string;
|
|
451
|
+
auth_time: string;
|
|
452
|
+
amr: string[];
|
|
453
|
+
ip: string;
|
|
454
|
+
user_agent: string;
|
|
455
|
+
created_at: string;
|
|
456
|
+
last_seen_at: string;
|
|
457
|
+
expires_at: string;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A linked sign-in of a user (`social:google`, `enterprise:<id>`, `adapter`, …). */
|
|
461
|
+
export interface ManagedIdentity {
|
|
462
|
+
connection: string;
|
|
463
|
+
subject: string;
|
|
464
|
+
profile: Record<string, unknown>;
|
|
465
|
+
created_at: string;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** `getUser`: the user with their identities, active sessions and two-factor state. */
|
|
469
|
+
export interface ManagedUserDetail {
|
|
470
|
+
user: ManagedUser;
|
|
471
|
+
identities: ManagedIdentity[];
|
|
472
|
+
sessions: ManagedSession[];
|
|
473
|
+
has_password: boolean;
|
|
474
|
+
mfa: { enabled: boolean; enabled_at?: string; recovery_codes_left?: number };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** One record for `importUsers`: the fields of `createUser` plus what an import may carry over. */
|
|
478
|
+
export interface ImportUser {
|
|
479
|
+
/** Keep the id from your old system (letters, digits and | _ . : @ + -, max 128). */
|
|
480
|
+
id?: string;
|
|
481
|
+
email?: string | null;
|
|
482
|
+
email_verified?: boolean;
|
|
483
|
+
/** E.164, e.g. +447700900000. */
|
|
484
|
+
phone_number?: string | null;
|
|
485
|
+
phone_number_verified?: boolean;
|
|
486
|
+
username?: string | null;
|
|
487
|
+
name?: string | null;
|
|
488
|
+
given_name?: string | null;
|
|
489
|
+
family_name?: string | null;
|
|
490
|
+
picture?: string | null;
|
|
491
|
+
locale?: string | null;
|
|
492
|
+
zoneinfo?: string | null;
|
|
493
|
+
birthdate?: string | null;
|
|
494
|
+
metadata?: Record<string, unknown>;
|
|
495
|
+
blocked?: boolean;
|
|
496
|
+
/** A plain password (at most 50 such records per call), or `password_hash` from your old system. */
|
|
497
|
+
password?: string;
|
|
498
|
+
/** bcrypt, argon2, PBKDF2 or Firebase scrypt hash: users keep their password. */
|
|
499
|
+
password_hash?: string;
|
|
500
|
+
/** Linked sign-ins, e.g. { connection: "social:google", subject: "1069…" }. */
|
|
501
|
+
identities?: { connection: string; subject: string }[];
|
|
502
|
+
/** Original dates, ISO 8601 (`Z` or an offset). */
|
|
503
|
+
created_at?: string;
|
|
504
|
+
last_login_at?: string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Fields of a new user (`createUser`); `updateUser` takes any of them. */
|
|
508
|
+
export type NewUser = Omit<ImportUser, "id" | "identities" | "created_at" | "last_login_at">;
|
|
509
|
+
|
|
510
|
+
export type ImportResult = { index: number; ok: boolean } & (
|
|
511
|
+
| { status: "created" | "updated"; id: string }
|
|
512
|
+
| { status: "skipped"; id: string; reason: string }
|
|
513
|
+
| { status: "failed"; error: string; details?: unknown }
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
/** Users and sessions of one tenant environment, with a client-credentials token. */
|
|
517
|
+
type ManagementScope = "management:users:read" | "management:users:write" | "management:sessions:write";
|
|
518
|
+
|
|
519
|
+
export class ManagementClient {
|
|
520
|
+
/** One token per scope: each call asks only for what it needs, so an app granted some scopes works. */
|
|
521
|
+
private readonly tokens = new Map<ManagementScope, { value: string; expiresAt: number }>();
|
|
522
|
+
private readonly fetcher: typeof fetch;
|
|
523
|
+
private readonly issuer: string;
|
|
524
|
+
|
|
525
|
+
constructor(private readonly options: ManagementOptions) {
|
|
526
|
+
this.fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
527
|
+
this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private async accessToken(scope: ManagementScope): Promise<string> {
|
|
531
|
+
const cached = this.tokens.get(scope);
|
|
532
|
+
if (cached && cached.expiresAt - 30_000 > Date.now()) return cached.value;
|
|
533
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
534
|
+
const result = await postForm<{ access_token: string; expires_in: number }>(
|
|
535
|
+
config.token_endpoint,
|
|
536
|
+
{ grant_type: "client_credentials", scope },
|
|
537
|
+
{ Authorization: `Basic ${btoa(`${encodeURIComponent(this.options.clientId)}:${encodeURIComponent(this.options.clientSecret)}`)}` },
|
|
538
|
+
this.fetcher,
|
|
539
|
+
);
|
|
540
|
+
this.tokens.set(scope, { value: result.access_token, expiresAt: Date.now() + result.expires_in * 1000 });
|
|
541
|
+
return result.access_token;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
545
|
+
const scope: ManagementScope = method === "GET" ? "management:users:read" : path.includes("/sessions") ? "management:sessions:write" : "management:users:write";
|
|
546
|
+
const res = await this.fetcher(`${this.issuer}/api/v1${path}`, {
|
|
547
|
+
method,
|
|
548
|
+
headers: { Authorization: `Bearer ${await this.accessToken(scope)}`, Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
549
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
550
|
+
});
|
|
551
|
+
if (res.status === 204) return undefined as T;
|
|
552
|
+
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
553
|
+
if (!res.ok) throw new NineError(String(json.error ?? "request_failed"), json.error_description ? String(json.error_description) : `HTTP ${res.status}`, res.status);
|
|
554
|
+
return json as T;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
listUsers(options: { query?: string; cursor?: string; limit?: number } = {}) {
|
|
558
|
+
const params = new URLSearchParams();
|
|
559
|
+
if (options.query) params.set("q", options.query);
|
|
560
|
+
if (options.cursor) params.set("cursor", options.cursor);
|
|
561
|
+
if (options.limit) params.set("limit", String(options.limit));
|
|
562
|
+
return this.request<{ users: ManagedUser[]; next_cursor: string | null; total: number }>("GET", `/users?${params}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** The user with their identities, active sessions and two-factor state. */
|
|
566
|
+
getUser(id: string) {
|
|
567
|
+
return this.request<ManagedUserDetail>("GET", `/users/${encodeURIComponent(id)}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
findUserByEmail(email: string) {
|
|
571
|
+
return this.findUser("email", email);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Looks a user up by email, phone number (E.164) or username; throws `not_found` (404) when unknown. */
|
|
575
|
+
findUser(type: "email" | "phone_number" | "username", value: string) {
|
|
576
|
+
return this.request<{ user: ManagedUser }>("GET", `/users/lookup?${type}=${encodeURIComponent(value)}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
createUser(input: NewUser) {
|
|
580
|
+
return this.request<{ user: ManagedUser }>("POST", "/users", input);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Bulk import, up to 500 users per call. Records may keep their old id and carry password
|
|
585
|
+
* hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities and original dates.
|
|
586
|
+
* Results are per record, in order; one bad record does not stop the others.
|
|
587
|
+
*/
|
|
588
|
+
importUsers(users: ImportUser[], options: { onConflict?: "skip" | "update"; source?: string } = {}) {
|
|
589
|
+
return this.request<{ results: ImportResult[] }>("POST", "/users/import", {
|
|
590
|
+
users,
|
|
591
|
+
...(options.onConflict ? { on_conflict: options.onConflict } : {}),
|
|
592
|
+
...(options.source ? { source: options.source } : {}),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Changes only the fields you send; `metadata` is replaced as a whole. */
|
|
597
|
+
updateUser(id: string, input: Partial<NewUser>) {
|
|
598
|
+
return this.request<{ user: ManagedUser }>("PATCH", `/users/${encodeURIComponent(id)}`, input);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
deleteUser(id: string) {
|
|
602
|
+
return this.request<void>("DELETE", `/users/${encodeURIComponent(id)}`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
blockUser(id: string, blocked = true) {
|
|
606
|
+
return this.updateUser(id, { blocked });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Single-use URL that signs the user in (magic links, handing a user over from your app).
|
|
611
|
+
* `returnTo` must be a registered redirect URL of one of your applications or a path on
|
|
612
|
+
* the login domain. Two-factor authentication still applies.
|
|
613
|
+
*/
|
|
614
|
+
createLoginLink(id: string, options: { returnTo?: string; ttlSeconds?: number } = {}) {
|
|
615
|
+
return this.request<{ url: string; expires_at: string }>("POST", `/users/${encodeURIComponent(id)}/login-link`, {
|
|
616
|
+
...(options.returnTo ? { return_to: options.returnTo } : {}),
|
|
617
|
+
...(options.ttlSeconds ? { ttl_seconds: options.ttlSeconds } : {}),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** The user's active sessions, most recently used first. */
|
|
622
|
+
listUserSessions(id: string) {
|
|
623
|
+
return this.request<{ sessions: ManagedSession[] }>("GET", `/users/${encodeURIComponent(id)}/sessions`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Signs the user out everywhere; returns how many sessions ended. */
|
|
627
|
+
revokeUserSessions(id: string) {
|
|
628
|
+
return this.request<{ revoked: number }>("DELETE", `/users/${encodeURIComponent(id)}/sessions`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Ends one session (its `sid`) and every token issued under it. */
|
|
632
|
+
revokeSession(sid: string) {
|
|
633
|
+
return this.request<void>("DELETE", `/sessions/${encodeURIComponent(sid)}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// Adapter protocol (keep users in your own database)
|
|
639
|
+
|
|
640
|
+
export interface AdapterIdentifier {
|
|
641
|
+
type: "email" | "username" | "phone";
|
|
642
|
+
value: string;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export interface AdapterUser {
|
|
646
|
+
id: string;
|
|
647
|
+
email?: string | null;
|
|
648
|
+
email_verified?: boolean | null;
|
|
649
|
+
phone_number?: string | null;
|
|
650
|
+
phone_number_verified?: boolean | null;
|
|
651
|
+
username?: string | null;
|
|
652
|
+
name?: string | null;
|
|
653
|
+
given_name?: string | null;
|
|
654
|
+
family_name?: string | null;
|
|
655
|
+
picture?: string | null;
|
|
656
|
+
locale?: string | null;
|
|
657
|
+
zoneinfo?: string | null;
|
|
658
|
+
birthdate?: string | null;
|
|
659
|
+
blocked?: boolean | null;
|
|
660
|
+
metadata?: Record<string, unknown> | null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* What 9in POSTs to `…/event`. `data.user_id` is your own user id. `otp.requested` carries
|
|
665
|
+
* `user_id, channel, to, code, purpose, expires_at`; `user.updated` and
|
|
666
|
+
* `user.password_changed` carry `user_id, fields`.
|
|
667
|
+
*/
|
|
668
|
+
export interface AdapterEvent {
|
|
669
|
+
id: string;
|
|
670
|
+
event_type: "otp.requested" | "user.created" | "user.updated" | "user.password_changed" | "session.revoked";
|
|
671
|
+
/** ISO 8601 UTC. */
|
|
672
|
+
created_at: string;
|
|
673
|
+
data: { user_id?: string; [field: string]: unknown };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export interface AdapterHandlers {
|
|
677
|
+
/** Shared secret from the console (Connections → User database). Several allow rotation. */
|
|
678
|
+
secret: string | string[];
|
|
679
|
+
/** Check a password. Return the user, "invalid" or "blocked". */
|
|
680
|
+
authenticate(identifier: AdapterIdentifier, password: string): Promise<AdapterUser | "invalid" | "blocked">;
|
|
681
|
+
/** Look a user up by identifier or id; null when unknown. */
|
|
682
|
+
findUser(query: { identifier: AdapterIdentifier } | { id: string }): Promise<AdapterUser | null>;
|
|
683
|
+
/** Notifications, including `otp.requested` when you deliver codes yourself. */
|
|
684
|
+
onEvent?(event: AdapterEvent): Promise<void>;
|
|
685
|
+
/** Signature tolerance in seconds (default 300). */
|
|
686
|
+
toleranceSeconds?: number;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const SIGNATURE_HEADER = "9in-signature";
|
|
690
|
+
|
|
691
|
+
async function hmac(secret: string, payload: string): Promise<string> {
|
|
692
|
+
const key = await crypto.subtle.importKey("raw", utf8(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
693
|
+
return toHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, utf8(payload))));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function safeEqual(a: string, b: string): boolean {
|
|
697
|
+
if (a.length !== b.length) return false;
|
|
698
|
+
let diff = 0;
|
|
699
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
700
|
+
return diff === 0;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Verifies a `9in-signature` header: t=<unix>,v1=<hex HMAC(t.METHOD.path.body)>. */
|
|
704
|
+
export async function verifySignature(secret: string | string[], header: string | null, method: string, path: string, body: string, toleranceSeconds = 300): Promise<boolean> {
|
|
705
|
+
if (!header) return false;
|
|
706
|
+
const parts = header.split(",").map((p) => p.trim());
|
|
707
|
+
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
|
|
708
|
+
const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
|
|
709
|
+
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !signatures.length) return false;
|
|
710
|
+
for (const s of Array.isArray(secret) ? secret : [secret]) {
|
|
711
|
+
const expected = await hmac(s, `${t}.${method.toUpperCase()}.${path}.${body}`);
|
|
712
|
+
if (signatures.some((sig) => safeEqual(sig, expected))) return true;
|
|
713
|
+
}
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export async function signPayload(secret: string, method: string, path: string, body: string): Promise<string> {
|
|
718
|
+
const t = Math.floor(Date.now() / 1000);
|
|
719
|
+
return `t=${t},v1=${await hmac(secret, `${t}.${method.toUpperCase()}.${path}.${body}`)}`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
// Webhooks
|
|
724
|
+
|
|
725
|
+
export type WebhookEventType =
|
|
726
|
+
| "user.created"
|
|
727
|
+
| "user.updated"
|
|
728
|
+
| "user.deleted"
|
|
729
|
+
| "user.login"
|
|
730
|
+
| "user.login_failed"
|
|
731
|
+
| "user.password_changed"
|
|
732
|
+
| "user.mfa_enabled"
|
|
733
|
+
| "user.mfa_disabled"
|
|
734
|
+
| "user.sessions_revoked"
|
|
735
|
+
| "session.revoked"
|
|
736
|
+
| "token.revoked"
|
|
737
|
+
| "webhook.test";
|
|
738
|
+
|
|
739
|
+
export interface WebhookEvent<Data extends Record<string, unknown> = Record<string, unknown>> {
|
|
740
|
+
/** Event id: the same on every retry and redelivery, so you can ignore duplicates. */
|
|
741
|
+
id: string;
|
|
742
|
+
/** The event name, also in the `9in-event-type` header. */
|
|
743
|
+
event_type: WebhookEventType;
|
|
744
|
+
/** ISO 8601 UTC. */
|
|
745
|
+
created_at: string;
|
|
746
|
+
/** Tenant id (tnt_…). */
|
|
747
|
+
tenant: string;
|
|
748
|
+
environment: "production" | "sandbox";
|
|
749
|
+
/**
|
|
750
|
+
* Event details, plus the `user` the event is about (null when there is none; for
|
|
751
|
+
* `user.deleted`, the user as it was just before the deletion).
|
|
752
|
+
*/
|
|
753
|
+
data: Data & { user?: ManagedUser | null };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export interface VerifyWebhookOptions {
|
|
757
|
+
/** Signature tolerance in seconds (default 300). */
|
|
758
|
+
toleranceSeconds?: number;
|
|
759
|
+
/** The path 9in posted to, when a proxy or framework rewrote `request.url` (default: its pathname). */
|
|
760
|
+
path?: string;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Verifies a webhook request from 9in (the `9in-signature` header over the raw body) and
|
|
765
|
+
* returns the parsed event. Throws `NineError` (`invalid_signature`, 401) otherwise. Reads
|
|
766
|
+
* the body, so pass `request.clone()` if you need it afterwards.
|
|
767
|
+
*
|
|
768
|
+
* const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
|
|
769
|
+
* if (event.event_type === "user.created") …
|
|
770
|
+
*/
|
|
771
|
+
export async function verifyWebhook(secret: string | string[], request: Request, options: VerifyWebhookOptions = {}): Promise<WebhookEvent> {
|
|
772
|
+
const body = await request.text();
|
|
773
|
+
const path = options.path ?? new URL(request.url).pathname;
|
|
774
|
+
const valid =
|
|
775
|
+
request.method.toUpperCase() === "POST" &&
|
|
776
|
+
(await verifySignature(secret, request.headers.get(SIGNATURE_HEADER), "POST", path, body, options.toleranceSeconds));
|
|
777
|
+
if (!valid) throw new NineError("invalid_signature", "webhook signature verification failed", 401);
|
|
778
|
+
try {
|
|
779
|
+
return JSON.parse(body) as WebhookEvent;
|
|
780
|
+
} catch {
|
|
781
|
+
throw new NineError("invalid_payload", "webhook body is not JSON", 400);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// Processing each event once
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Key-value store for `processEventOnce`. `add` must be atomic (insert only when the key is
|
|
790
|
+
* absent or expired): use a Durable Object, D1/SQL with a unique key, or Redis `SET NX EX`.
|
|
791
|
+
* Workers KV is eventually consistent and not suitable.
|
|
792
|
+
*/
|
|
793
|
+
export interface EventStore {
|
|
794
|
+
add(key: string, value: string, ttlSeconds: number): Promise<boolean>;
|
|
795
|
+
get(key: string): Promise<string | null>;
|
|
796
|
+
set(key: string, value: string, ttlSeconds: number): Promise<void>;
|
|
797
|
+
delete(key: string): Promise<void>;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** In-memory EventStore: one process only (tests, single-instance servers). */
|
|
801
|
+
export class MemoryEventStore implements EventStore {
|
|
802
|
+
private readonly items = new Map<string, { value: string; expiresAt: number }>();
|
|
803
|
+
|
|
804
|
+
private live(key: string) {
|
|
805
|
+
const item = this.items.get(key);
|
|
806
|
+
if (item && item.expiresAt <= Date.now()) {
|
|
807
|
+
this.items.delete(key);
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
return item;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async add(key: string, value: string, ttlSeconds: number): Promise<boolean> {
|
|
814
|
+
if (this.live(key)) return false;
|
|
815
|
+
this.items.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
816
|
+
if (this.items.size > 10_000) for (const k of this.items.keys()) this.live(k);
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async get(key: string): Promise<string | null> {
|
|
821
|
+
return this.live(key)?.value ?? null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async set(key: string, value: string, ttlSeconds: number): Promise<void> {
|
|
825
|
+
this.items.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async delete(key: string): Promise<void> {
|
|
829
|
+
this.items.delete(key);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export interface ProcessOnceOptions {
|
|
834
|
+
/** How long a processed id is remembered (default 3 days; 9in retries for about a day). */
|
|
835
|
+
ttlSeconds?: number;
|
|
836
|
+
/** How long a running handler holds the id before another delivery may take over (default 300). */
|
|
837
|
+
lockSeconds?: number;
|
|
838
|
+
/** Key prefix (default "9in:event:"). */
|
|
839
|
+
prefix?: string;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export type ProcessOnceResult<T> = { status: "processed"; result: T } | { status: "duplicate" } | { status: "in_progress" };
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Runs `handler` at most once per event id. 9in keeps the id on every retry and redelivery.
|
|
846
|
+
*
|
|
847
|
+
* - `processed`: first delivery, handler succeeded → answer 2xx.
|
|
848
|
+
* - `duplicate`: already processed → answer 2xx so 9in stops retrying.
|
|
849
|
+
* - `in_progress`: another delivery of the same event is still running → answer 409 so
|
|
850
|
+
* 9in tries again later.
|
|
851
|
+
* - The handler threw: the id is released and the error rethrown → answer 5xx so 9in retries.
|
|
852
|
+
*
|
|
853
|
+
* const event = await verifyWebhook(secret, request);
|
|
854
|
+
* const outcome = await processEventOnce(store, event.id, () => handle(event));
|
|
855
|
+
* return new Response(null, { status: outcome.status === "in_progress" ? 409 : 204 });
|
|
856
|
+
*/
|
|
857
|
+
export async function processEventOnce<T>(store: EventStore, eventId: string, handler: () => T | Promise<T>, options: ProcessOnceOptions = {}): Promise<ProcessOnceResult<T>> {
|
|
858
|
+
if (!eventId) throw new NineError("invalid_event", "event id required");
|
|
859
|
+
const key = `${options.prefix ?? "9in:event:"}${eventId}`;
|
|
860
|
+
if (!(await store.add(key, "processing", options.lockSeconds ?? 300))) {
|
|
861
|
+
return (await store.get(key)) === "done" ? { status: "duplicate" } : { status: "in_progress" };
|
|
862
|
+
}
|
|
863
|
+
let result: T;
|
|
864
|
+
try {
|
|
865
|
+
result = await handler();
|
|
866
|
+
} catch (error) {
|
|
867
|
+
await store.delete(key);
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
await store.set(key, "done", options.ttlSeconds ?? 3 * 24 * 3600);
|
|
871
|
+
return { status: "processed", result };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Fetch-API handler for the adapter protocol. Mount it at your adapter base URL (it serves
|
|
876
|
+
* `…/authenticate`, `…/user` and `…/event`) in Cloudflare Workers, Deno, Bun, Hono, or Node
|
|
877
|
+
* with any Request/Response bridge (Express: `@whatwg-node/server`, Next.js route handlers…).
|
|
878
|
+
*/
|
|
879
|
+
export function adapterHandler(handlers: AdapterHandlers): (request: Request) => Promise<Response> {
|
|
880
|
+
const signingSecret = Array.isArray(handlers.secret) ? handlers.secret[0]! : handlers.secret;
|
|
881
|
+
return async (request) => {
|
|
882
|
+
const url = new URL(request.url);
|
|
883
|
+
const body = await request.text();
|
|
884
|
+
const requestId = request.headers.get("9in-request-id") ?? "";
|
|
885
|
+
const respond = async (status: number, payload: unknown) => {
|
|
886
|
+
const text = payload === null ? "" : JSON.stringify(payload);
|
|
887
|
+
const headers = new Headers({ [SIGNATURE_HEADER]: await signPayload(signingSecret, "POST", `${url.pathname}#${requestId}`, text) });
|
|
888
|
+
if (text) headers.set("Content-Type", "application/json");
|
|
889
|
+
return new Response(status === 204 ? null : text, { status, headers });
|
|
890
|
+
};
|
|
891
|
+
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
|
892
|
+
if (!(await verifySignature(handlers.secret, request.headers.get(SIGNATURE_HEADER), "POST", url.pathname, body, handlers.toleranceSeconds))) {
|
|
893
|
+
return new Response(JSON.stringify({ error: "invalid_signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
894
|
+
}
|
|
895
|
+
let input: Record<string, unknown>;
|
|
896
|
+
try {
|
|
897
|
+
input = JSON.parse(body || "{}") as Record<string, unknown>;
|
|
898
|
+
} catch {
|
|
899
|
+
return respond(400, { error: "invalid_json" });
|
|
900
|
+
}
|
|
901
|
+
const route = url.pathname.split("/").pop();
|
|
902
|
+
if (route === "authenticate") {
|
|
903
|
+
const result = await handlers.authenticate(input.identifier as AdapterIdentifier, String(input.password ?? ""));
|
|
904
|
+
if (result === "invalid") return respond(401, { error: "invalid_credentials" });
|
|
905
|
+
if (result === "blocked") return respond(403, { error: "blocked" });
|
|
906
|
+
return respond(200, { user: result });
|
|
907
|
+
}
|
|
908
|
+
if (route === "user") {
|
|
909
|
+
const user = await handlers.findUser(input.id ? { id: String(input.id) } : { identifier: input.identifier as AdapterIdentifier });
|
|
910
|
+
return user ? respond(200, { user }) : respond(404, { error: "not_found" });
|
|
911
|
+
}
|
|
912
|
+
if (route === "event") {
|
|
913
|
+
await handlers.onEvent?.(input as unknown as AdapterEvent);
|
|
914
|
+
return respond(204, null);
|
|
915
|
+
}
|
|
916
|
+
return respond(404, { error: "not_found" });
|
|
917
|
+
};
|
|
918
|
+
}
|