@9in-io/sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -2
- package/dist/index.d.ts +119 -0
- package/dist/index.js +231 -0
- package/dist/server.d.ts +299 -0
- package/dist/server.js +490 -0
- package/dist/util.d.ts +39 -0
- package/dist/util.js +96 -0
- package/package.json +49 -4
package/dist/server.js
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { NineError, decodeJwt, discover, normalizeIssuer, postForm, toHex, utf8 } from "./util.js";
|
|
2
|
+
export { NineError, normalizeIssuer } from "./util.js";
|
|
3
|
+
/** WebCrypto parameters for each JWS algorithm 9in signs with. */
|
|
4
|
+
const ALGORITHMS = {
|
|
5
|
+
RS256: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: "RSASSA-PKCS1-v1_5" },
|
|
6
|
+
RS384: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: "RSASSA-PKCS1-v1_5" },
|
|
7
|
+
RS512: { import: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: "RSASSA-PKCS1-v1_5" },
|
|
8
|
+
PS256: { import: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 } },
|
|
9
|
+
PS384: { import: { name: "RSA-PSS", hash: "SHA-384" }, verify: { name: "RSA-PSS", saltLength: 48 } },
|
|
10
|
+
PS512: { import: { name: "RSA-PSS", hash: "SHA-512" }, verify: { name: "RSA-PSS", saltLength: 64 } },
|
|
11
|
+
ES256: { import: { name: "ECDSA", namedCurve: "P-256" }, verify: { name: "ECDSA", hash: "SHA-256" } },
|
|
12
|
+
ES384: { import: { name: "ECDSA", namedCurve: "P-384" }, verify: { name: "ECDSA", hash: "SHA-384" } },
|
|
13
|
+
EdDSA: { import: { name: "Ed25519" }, verify: "Ed25519" },
|
|
14
|
+
};
|
|
15
|
+
const HMAC_HASH = { HS256: "SHA-256", HS384: "SHA-384", HS512: "SHA-512" };
|
|
16
|
+
const jwksCache = new Map();
|
|
17
|
+
const keyCache = new Map();
|
|
18
|
+
/**
|
|
19
|
+
* kid → time it was last looked up without success. An unknown kid (e.g. right after a key
|
|
20
|
+
* rotation) triggers one JWKS refetch; junk kids are then ignored for a minute.
|
|
21
|
+
*/
|
|
22
|
+
const missingKids = new Map();
|
|
23
|
+
async function jwks(issuer, fetcher, force = false) {
|
|
24
|
+
const hit = jwksCache.get(issuer);
|
|
25
|
+
if (hit && !force && Date.now() - hit.fetchedAt < 10 * 60_000)
|
|
26
|
+
return hit.keys;
|
|
27
|
+
const config = await discover(issuer, fetcher);
|
|
28
|
+
const res = await fetcher(config.jwks_uri);
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new NineError("jwks_unavailable", `HTTP ${res.status}`, res.status);
|
|
31
|
+
const body = (await res.json());
|
|
32
|
+
jwksCache.set(issuer, { keys: body.keys, fetchedAt: Date.now() });
|
|
33
|
+
return body.keys;
|
|
34
|
+
}
|
|
35
|
+
async function findKey(issuer, kid, fetcher) {
|
|
36
|
+
const keys = await jwks(issuer, fetcher);
|
|
37
|
+
const known = keys.find((k) => k.kid === kid);
|
|
38
|
+
if (known)
|
|
39
|
+
return known;
|
|
40
|
+
const missKey = `${issuer}|${String(kid)}`;
|
|
41
|
+
const missedAt = missingKids.get(missKey);
|
|
42
|
+
if (missedAt && Date.now() - missedAt < 60_000)
|
|
43
|
+
return undefined;
|
|
44
|
+
const refreshed = (await jwks(issuer, fetcher, true)).find((k) => k.kid === kid);
|
|
45
|
+
if (!refreshed) {
|
|
46
|
+
missingKids.set(missKey, Date.now());
|
|
47
|
+
if (missingKids.size > 1000)
|
|
48
|
+
missingKids.clear();
|
|
49
|
+
}
|
|
50
|
+
return refreshed;
|
|
51
|
+
}
|
|
52
|
+
async function importPublicKey(jwk, alg) {
|
|
53
|
+
const id = `${alg}:${jwk.kid}:${(jwk.n ?? jwk.x ?? "").slice(0, 32)}`;
|
|
54
|
+
let key = keyCache.get(id);
|
|
55
|
+
if (!key) {
|
|
56
|
+
const { kty, n, e, crv, x, y } = jwk;
|
|
57
|
+
const material = kty === "RSA" ? { kty, n, e } : kty === "EC" ? { kty, crv, x, y } : { kty, crv, x };
|
|
58
|
+
key = await crypto.subtle.importKey("jwk", material, ALGORITHMS[alg].import, false, ["verify"]);
|
|
59
|
+
if (keyCache.size > 200)
|
|
60
|
+
keyCache.clear();
|
|
61
|
+
keyCache.set(id, key);
|
|
62
|
+
}
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
const ACCESS_TOKEN_TYPES = new Set(["at+jwt", "application/at+jwt"]);
|
|
66
|
+
/**
|
|
67
|
+
* Verifies a 9in JWT access token locally: signature (against your tenant's JWKS, or your
|
|
68
|
+
* API's shared secret for HMAC keys), token type, issuer, audience, expiry and required
|
|
69
|
+
* scopes. Keys are cached and refetched once when a new key appears.
|
|
70
|
+
*
|
|
71
|
+
* This is a local check: a token revoked before it expires still passes. Use
|
|
72
|
+
* `createTokenVerifier` (hybrid mode) when revocation must take effect at once.
|
|
73
|
+
*/
|
|
74
|
+
export async function verifyAccessToken(token, options) {
|
|
75
|
+
if (!token)
|
|
76
|
+
throw new NineError("invalid_token", "missing token", 401);
|
|
77
|
+
const fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
78
|
+
const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
79
|
+
let parts;
|
|
80
|
+
try {
|
|
81
|
+
parts = decodeJwt(token);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new NineError("invalid_token", "malformed token", 401);
|
|
85
|
+
}
|
|
86
|
+
const { header, payload, signingInput, signature } = parts;
|
|
87
|
+
const alg = String(header.alg);
|
|
88
|
+
if (!ACCESS_TOKEN_TYPES.has(String(header.typ).toLowerCase()))
|
|
89
|
+
throw new NineError("invalid_token", "not an access token", 401);
|
|
90
|
+
let valid;
|
|
91
|
+
if (HMAC_HASH[alg]) {
|
|
92
|
+
if (!options.secret)
|
|
93
|
+
throw new NineError("invalid_token", "HMAC-signed token: pass the API's secret", 401);
|
|
94
|
+
const key = await crypto.subtle.importKey("raw", utf8(options.secret), { name: "HMAC", hash: HMAC_HASH[alg] }, false, ["verify"]);
|
|
95
|
+
valid = await crypto.subtle.verify("HMAC", key, signature, utf8(signingInput));
|
|
96
|
+
}
|
|
97
|
+
else if (ALGORITHMS[alg]) {
|
|
98
|
+
const jwk = await findKey(issuer, header.kid, fetcher);
|
|
99
|
+
if (!jwk)
|
|
100
|
+
throw new NineError("invalid_token", "unknown signing key", 401);
|
|
101
|
+
// The key decides the algorithm, never the token alone (no algorithm confusion).
|
|
102
|
+
if (jwk.alg && jwk.alg !== alg)
|
|
103
|
+
throw new NineError("invalid_token", "algorithm does not match the key", 401);
|
|
104
|
+
valid = await crypto.subtle.verify(ALGORITHMS[alg].verify, await importPublicKey(jwk, alg), signature, utf8(signingInput));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
throw new NineError("invalid_token", "unsupported algorithm", 401);
|
|
108
|
+
}
|
|
109
|
+
if (!valid)
|
|
110
|
+
throw new NineError("invalid_token", "bad signature", 401);
|
|
111
|
+
checkClaims(payload, issuer, options);
|
|
112
|
+
return payload;
|
|
113
|
+
}
|
|
114
|
+
function checkClaims(payload, issuer, options) {
|
|
115
|
+
const now = Math.floor(Date.now() / 1000);
|
|
116
|
+
const skew = options.clockTolerance ?? 30;
|
|
117
|
+
if (String(payload.iss).replace(/\/$/, "") !== issuer)
|
|
118
|
+
throw new NineError("invalid_token", "issuer mismatch", 401);
|
|
119
|
+
if (typeof payload.exp !== "number" || payload.exp + skew < now)
|
|
120
|
+
throw new NineError("invalid_token", "token expired", 401);
|
|
121
|
+
if (typeof payload.nbf === "number" && payload.nbf - skew > now)
|
|
122
|
+
throw new NineError("invalid_token", "token not yet valid", 401);
|
|
123
|
+
if (options.audience) {
|
|
124
|
+
const wanted = Array.isArray(options.audience) ? options.audience : [options.audience];
|
|
125
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
126
|
+
if (!wanted.some((a) => aud.includes(a)))
|
|
127
|
+
throw new NineError("invalid_token", "audience mismatch", 401);
|
|
128
|
+
}
|
|
129
|
+
checkScope(payload, options.scope);
|
|
130
|
+
}
|
|
131
|
+
function checkScope(payload, scope) {
|
|
132
|
+
if (!scope)
|
|
133
|
+
return;
|
|
134
|
+
const required = Array.isArray(scope) ? scope : scope.split(" ").filter(Boolean);
|
|
135
|
+
const granted = new Set(String(payload.scope ?? "").split(" "));
|
|
136
|
+
const missing = required.filter((s) => !granted.has(s));
|
|
137
|
+
if (missing.length)
|
|
138
|
+
throw new NineError("insufficient_scope", `missing ${missing.join(", ")}`, 403);
|
|
139
|
+
}
|
|
140
|
+
const OPAQUE_PREFIX = "9in_at_";
|
|
141
|
+
async function cacheKey(token) {
|
|
142
|
+
return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(token))));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Token verification with a choice of trade-off between speed and revocation (SSOfy's
|
|
146
|
+
* "hybrid" validation, kept): see `mode`.
|
|
147
|
+
*
|
|
148
|
+
* const verifier = createTokenVerifier({ issuer, audience: "https://api.acme.com", clientId, clientSecret });
|
|
149
|
+
* const claims = await verifier.verify(bearer, { scope: "orders:read" });
|
|
150
|
+
*
|
|
151
|
+
* // webhook endpoint (token.revoked, session.revoked, user.* events)
|
|
152
|
+
* verifier.handleEvent(await verifyWebhook(secret, request));
|
|
153
|
+
*/
|
|
154
|
+
export function createTokenVerifier(options) {
|
|
155
|
+
const mode = options.mode ?? "hybrid";
|
|
156
|
+
const fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
157
|
+
const issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
158
|
+
const ttl = (options.cacheTtl ?? 300) * 1000;
|
|
159
|
+
const size = options.cacheSize ?? 10_000;
|
|
160
|
+
const cache = new Map();
|
|
161
|
+
const inflight = new Map();
|
|
162
|
+
if (mode !== "local" && (!options.clientId || !options.clientSecret)) {
|
|
163
|
+
throw new NineError("invalid_options", `${mode} mode needs clientId and clientSecret (a confidential application) for introspection`);
|
|
164
|
+
}
|
|
165
|
+
async function introspect(token) {
|
|
166
|
+
const config = await discover(issuer, fetcher);
|
|
167
|
+
const endpoint = config.introspection_endpoint ?? `${issuer}/oauth/introspect`;
|
|
168
|
+
const body = await postForm(endpoint, { token }, { Authorization: `Basic ${btoa(`${encodeURIComponent(options.clientId)}:${encodeURIComponent(options.clientSecret)}`)}` }, fetcher);
|
|
169
|
+
if (body.active !== true || body.token_type === "refresh_token")
|
|
170
|
+
return null;
|
|
171
|
+
return body;
|
|
172
|
+
}
|
|
173
|
+
function remember(key, entry) {
|
|
174
|
+
cache.delete(key);
|
|
175
|
+
cache.set(key, entry);
|
|
176
|
+
while (cache.size > size)
|
|
177
|
+
cache.delete(cache.keys().next().value);
|
|
178
|
+
}
|
|
179
|
+
/** The live claims (`local` for a JWT, once 9in confirms it is active), or null when revoked. */
|
|
180
|
+
async function lookup(token, local) {
|
|
181
|
+
const key = await cacheKey(token);
|
|
182
|
+
if (mode === "hybrid") {
|
|
183
|
+
const hit = cache.get(key);
|
|
184
|
+
if (hit && hit.until > Date.now()) {
|
|
185
|
+
remember(key, hit); // most recently used
|
|
186
|
+
return hit.claims;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let pending = inflight.get(key);
|
|
190
|
+
if (!pending) {
|
|
191
|
+
pending = introspect(token).finally(() => inflight.delete(key));
|
|
192
|
+
inflight.set(key, pending);
|
|
193
|
+
}
|
|
194
|
+
const answer = await pending;
|
|
195
|
+
const claims = answer && local ? local : answer;
|
|
196
|
+
if (mode === "hybrid") {
|
|
197
|
+
const expiry = (claims?.exp ?? local?.exp ?? 0) * 1000;
|
|
198
|
+
// A revoked token never comes back: remember the refusal until it expires.
|
|
199
|
+
const until = claims ? Math.min(Date.now() + ttl, expiry) : expiry ? expiry : Date.now() + 60_000;
|
|
200
|
+
if (until > Date.now())
|
|
201
|
+
remember(key, { claims, until });
|
|
202
|
+
}
|
|
203
|
+
return claims;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
async verify(token, extra = {}) {
|
|
207
|
+
if (!token)
|
|
208
|
+
throw new NineError("invalid_token", "missing token", 401);
|
|
209
|
+
const opaque = token.startsWith(OPAQUE_PREFIX);
|
|
210
|
+
if (opaque && mode === "local")
|
|
211
|
+
throw new NineError("invalid_token", "opaque tokens need hybrid or introspect mode", 401);
|
|
212
|
+
const local = opaque ? null : await verifyAccessToken(token, { ...options, scope: extra.scope });
|
|
213
|
+
if (mode === "local")
|
|
214
|
+
return local;
|
|
215
|
+
const claims = await lookup(token, local);
|
|
216
|
+
if (!claims)
|
|
217
|
+
throw new NineError("invalid_token", "token revoked", 401);
|
|
218
|
+
if (!local)
|
|
219
|
+
checkClaims(claims, issuer, { ...options, scope: extra.scope });
|
|
220
|
+
return claims;
|
|
221
|
+
},
|
|
222
|
+
handleEvent(event) {
|
|
223
|
+
const data = event.data ?? {};
|
|
224
|
+
let match = null;
|
|
225
|
+
if (event.type === "token.revoked" && data.type === "access_token" && data.jti)
|
|
226
|
+
match = (c) => c.jti === data.jti;
|
|
227
|
+
else if (event.type === "session.revoked" && data.sid)
|
|
228
|
+
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;
|
|
231
|
+
}
|
|
232
|
+
if (!match)
|
|
233
|
+
return 0;
|
|
234
|
+
let evicted = 0;
|
|
235
|
+
for (const [key, entry] of cache) {
|
|
236
|
+
if (entry.claims && match(entry.claims)) {
|
|
237
|
+
cache.delete(key);
|
|
238
|
+
evicted++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return evicted;
|
|
242
|
+
},
|
|
243
|
+
clear() {
|
|
244
|
+
cache.clear();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export class ManagementClient {
|
|
249
|
+
options;
|
|
250
|
+
/** One token per scope: each call asks only for what it needs, so an app granted some scopes works. */
|
|
251
|
+
tokens = new Map();
|
|
252
|
+
fetcher;
|
|
253
|
+
issuer;
|
|
254
|
+
constructor(options) {
|
|
255
|
+
this.options = options;
|
|
256
|
+
this.fetcher = options.fetch ?? fetch.bind(globalThis);
|
|
257
|
+
this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
|
|
258
|
+
}
|
|
259
|
+
async accessToken(scope) {
|
|
260
|
+
const cached = this.tokens.get(scope);
|
|
261
|
+
if (cached && cached.expiresAt - 30_000 > Date.now())
|
|
262
|
+
return cached.value;
|
|
263
|
+
const config = await discover(this.issuer, this.fetcher);
|
|
264
|
+
const result = await postForm(config.token_endpoint, { grant_type: "client_credentials", scope }, { Authorization: `Basic ${btoa(`${encodeURIComponent(this.options.clientId)}:${encodeURIComponent(this.options.clientSecret)}`)}` }, this.fetcher);
|
|
265
|
+
this.tokens.set(scope, { value: result.access_token, expiresAt: Date.now() + result.expires_in * 1000 });
|
|
266
|
+
return result.access_token;
|
|
267
|
+
}
|
|
268
|
+
async request(method, path, body) {
|
|
269
|
+
const scope = method === "GET" ? "management:users:read" : path.includes("/sessions") ? "management:sessions:write" : "management:users:write";
|
|
270
|
+
const res = await this.fetcher(`${this.issuer}/api/v1${path}`, {
|
|
271
|
+
method,
|
|
272
|
+
headers: { Authorization: `Bearer ${await this.accessToken(scope)}`, Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
273
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
274
|
+
});
|
|
275
|
+
if (res.status === 204)
|
|
276
|
+
return undefined;
|
|
277
|
+
const json = (await res.json().catch(() => ({})));
|
|
278
|
+
if (!res.ok)
|
|
279
|
+
throw new NineError(String(json.error ?? "request_failed"), json.error_description ? String(json.error_description) : `HTTP ${res.status}`, res.status);
|
|
280
|
+
return json;
|
|
281
|
+
}
|
|
282
|
+
listUsers(options = {}) {
|
|
283
|
+
const params = new URLSearchParams();
|
|
284
|
+
if (options.query)
|
|
285
|
+
params.set("q", options.query);
|
|
286
|
+
if (options.cursor)
|
|
287
|
+
params.set("cursor", options.cursor);
|
|
288
|
+
if (options.limit)
|
|
289
|
+
params.set("limit", String(options.limit));
|
|
290
|
+
return this.request("GET", `/users?${params}`);
|
|
291
|
+
}
|
|
292
|
+
getUser(id) {
|
|
293
|
+
return this.request("GET", `/users/${encodeURIComponent(id)}`);
|
|
294
|
+
}
|
|
295
|
+
findUserByEmail(email) {
|
|
296
|
+
return this.request("GET", `/users/lookup?email=${encodeURIComponent(email)}`);
|
|
297
|
+
}
|
|
298
|
+
createUser(input) {
|
|
299
|
+
return this.request("POST", "/users", input);
|
|
300
|
+
}
|
|
301
|
+
updateUser(id, input) {
|
|
302
|
+
return this.request("PATCH", `/users/${encodeURIComponent(id)}`, input);
|
|
303
|
+
}
|
|
304
|
+
deleteUser(id) {
|
|
305
|
+
return this.request("DELETE", `/users/${encodeURIComponent(id)}`);
|
|
306
|
+
}
|
|
307
|
+
blockUser(id, blocked = true) {
|
|
308
|
+
return this.updateUser(id, { blocked });
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Single-use URL that signs the user in (magic links, handing a user over from your app).
|
|
312
|
+
* `returnTo` must be a registered redirect URL of one of your applications or a path on
|
|
313
|
+
* the login domain. Two-factor authentication still applies.
|
|
314
|
+
*/
|
|
315
|
+
createLoginLink(id, options = {}) {
|
|
316
|
+
return this.request("POST", `/users/${encodeURIComponent(id)}/login-link`, {
|
|
317
|
+
...(options.returnTo ? { return_to: options.returnTo } : {}),
|
|
318
|
+
...(options.ttlSeconds ? { ttl_seconds: options.ttlSeconds } : {}),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
revokeUserSessions(id) {
|
|
322
|
+
return this.request("DELETE", `/users/${encodeURIComponent(id)}/sessions`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const SIGNATURE_HEADER = "9in-signature";
|
|
326
|
+
async function hmac(secret, payload) {
|
|
327
|
+
const key = await crypto.subtle.importKey("raw", utf8(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
328
|
+
return toHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, utf8(payload))));
|
|
329
|
+
}
|
|
330
|
+
function safeEqual(a, b) {
|
|
331
|
+
if (a.length !== b.length)
|
|
332
|
+
return false;
|
|
333
|
+
let diff = 0;
|
|
334
|
+
for (let i = 0; i < a.length; i++)
|
|
335
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
336
|
+
return diff === 0;
|
|
337
|
+
}
|
|
338
|
+
/** Verifies a `9in-signature` header: t=<unix>,v1=<hex HMAC(t.METHOD.path.body)>. */
|
|
339
|
+
export async function verifySignature(secret, header, method, path, body, toleranceSeconds = 300) {
|
|
340
|
+
if (!header)
|
|
341
|
+
return false;
|
|
342
|
+
const parts = header.split(",").map((p) => p.trim());
|
|
343
|
+
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
|
|
344
|
+
const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
|
|
345
|
+
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !signatures.length)
|
|
346
|
+
return false;
|
|
347
|
+
for (const s of Array.isArray(secret) ? secret : [secret]) {
|
|
348
|
+
const expected = await hmac(s, `${t}.${method.toUpperCase()}.${path}.${body}`);
|
|
349
|
+
if (signatures.some((sig) => safeEqual(sig, expected)))
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
export async function signPayload(secret, method, path, body) {
|
|
355
|
+
const t = Math.floor(Date.now() / 1000);
|
|
356
|
+
return `t=${t},v1=${await hmac(secret, `${t}.${method.toUpperCase()}.${path}.${body}`)}`;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Verifies a webhook request from 9in (the `9in-signature` header over the raw body) and
|
|
360
|
+
* returns the parsed event. Throws `NineError` (`invalid_signature`, 401) otherwise. Reads
|
|
361
|
+
* the body, so pass `request.clone()` if you need it afterwards.
|
|
362
|
+
*
|
|
363
|
+
* const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
|
|
364
|
+
* if (event.type === "user.created") …
|
|
365
|
+
*/
|
|
366
|
+
export async function verifyWebhook(secret, request, options = {}) {
|
|
367
|
+
const body = await request.text();
|
|
368
|
+
const path = options.path ?? new URL(request.url).pathname;
|
|
369
|
+
const valid = request.method.toUpperCase() === "POST" &&
|
|
370
|
+
(await verifySignature(secret, request.headers.get(SIGNATURE_HEADER), "POST", path, body, options.toleranceSeconds));
|
|
371
|
+
if (!valid)
|
|
372
|
+
throw new NineError("invalid_signature", "webhook signature verification failed", 401);
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(body);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
throw new NineError("invalid_payload", "webhook body is not JSON", 400);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** In-memory EventStore: one process only (tests, single-instance servers). */
|
|
381
|
+
export class MemoryEventStore {
|
|
382
|
+
items = new Map();
|
|
383
|
+
live(key) {
|
|
384
|
+
const item = this.items.get(key);
|
|
385
|
+
if (item && item.expiresAt <= Date.now()) {
|
|
386
|
+
this.items.delete(key);
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
return item;
|
|
390
|
+
}
|
|
391
|
+
async add(key, value, ttlSeconds) {
|
|
392
|
+
if (this.live(key))
|
|
393
|
+
return false;
|
|
394
|
+
this.items.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
395
|
+
if (this.items.size > 10_000)
|
|
396
|
+
for (const k of this.items.keys())
|
|
397
|
+
this.live(k);
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
async get(key) {
|
|
401
|
+
return this.live(key)?.value ?? null;
|
|
402
|
+
}
|
|
403
|
+
async set(key, value, ttlSeconds) {
|
|
404
|
+
this.items.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
405
|
+
}
|
|
406
|
+
async delete(key) {
|
|
407
|
+
this.items.delete(key);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Runs `handler` at most once per event id. 9in keeps the id on every retry and redelivery.
|
|
412
|
+
*
|
|
413
|
+
* - `processed`: first delivery, handler succeeded → answer 2xx.
|
|
414
|
+
* - `duplicate`: already processed → answer 2xx so 9in stops retrying.
|
|
415
|
+
* - `in_progress`: another delivery of the same event is still running → answer 409 so
|
|
416
|
+
* 9in tries again later.
|
|
417
|
+
* - The handler threw: the id is released and the error rethrown → answer 5xx so 9in retries.
|
|
418
|
+
*
|
|
419
|
+
* const event = await verifyWebhook(secret, request);
|
|
420
|
+
* const outcome = await processEventOnce(store, event.id, () => handle(event));
|
|
421
|
+
* return new Response(null, { status: outcome.status === "in_progress" ? 409 : 204 });
|
|
422
|
+
*/
|
|
423
|
+
export async function processEventOnce(store, eventId, handler, options = {}) {
|
|
424
|
+
if (!eventId)
|
|
425
|
+
throw new NineError("invalid_event", "event id required");
|
|
426
|
+
const key = `${options.prefix ?? "9in:event:"}${eventId}`;
|
|
427
|
+
if (!(await store.add(key, "processing", options.lockSeconds ?? 300))) {
|
|
428
|
+
return (await store.get(key)) === "done" ? { status: "duplicate" } : { status: "in_progress" };
|
|
429
|
+
}
|
|
430
|
+
let result;
|
|
431
|
+
try {
|
|
432
|
+
result = await handler();
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
await store.delete(key);
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
await store.set(key, "done", options.ttlSeconds ?? 3 * 24 * 3600);
|
|
439
|
+
return { status: "processed", result };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Fetch-API handler for the adapter protocol. Mount it at your adapter base URL (it serves
|
|
443
|
+
* `…/authenticate`, `…/user` and `…/event`) in Cloudflare Workers, Deno, Bun, Hono, or Node
|
|
444
|
+
* with any Request/Response bridge (Express: `@whatwg-node/server`, Next.js route handlers…).
|
|
445
|
+
*/
|
|
446
|
+
export function adapterHandler(handlers) {
|
|
447
|
+
const signingSecret = Array.isArray(handlers.secret) ? handlers.secret[0] : handlers.secret;
|
|
448
|
+
return async (request) => {
|
|
449
|
+
const url = new URL(request.url);
|
|
450
|
+
const body = await request.text();
|
|
451
|
+
const requestId = request.headers.get("9in-request-id") ?? "";
|
|
452
|
+
const respond = async (status, payload) => {
|
|
453
|
+
const text = payload === null ? "" : JSON.stringify(payload);
|
|
454
|
+
const headers = new Headers({ [SIGNATURE_HEADER]: await signPayload(signingSecret, "POST", `${url.pathname}#${requestId}`, text) });
|
|
455
|
+
if (text)
|
|
456
|
+
headers.set("Content-Type", "application/json");
|
|
457
|
+
return new Response(status === 204 ? null : text, { status, headers });
|
|
458
|
+
};
|
|
459
|
+
if (request.method !== "POST")
|
|
460
|
+
return new Response("Method not allowed", { status: 405 });
|
|
461
|
+
if (!(await verifySignature(handlers.secret, request.headers.get(SIGNATURE_HEADER), "POST", url.pathname, body, handlers.toleranceSeconds))) {
|
|
462
|
+
return new Response(JSON.stringify({ error: "invalid_signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
463
|
+
}
|
|
464
|
+
let input;
|
|
465
|
+
try {
|
|
466
|
+
input = JSON.parse(body || "{}");
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return respond(400, { error: "invalid_json" });
|
|
470
|
+
}
|
|
471
|
+
const route = url.pathname.split("/").pop();
|
|
472
|
+
if (route === "authenticate") {
|
|
473
|
+
const result = await handlers.authenticate(input.identifier, String(input.password ?? ""));
|
|
474
|
+
if (result === "invalid")
|
|
475
|
+
return respond(401, { error: "invalid_credentials" });
|
|
476
|
+
if (result === "blocked")
|
|
477
|
+
return respond(403, { error: "blocked" });
|
|
478
|
+
return respond(200, { user: result });
|
|
479
|
+
}
|
|
480
|
+
if (route === "user") {
|
|
481
|
+
const user = await handlers.findUser(input.id ? { id: String(input.id) } : { identifier: input.identifier });
|
|
482
|
+
return user ? respond(200, { user }) : respond(404, { error: "not_found" });
|
|
483
|
+
}
|
|
484
|
+
if (route === "event") {
|
|
485
|
+
await handlers.onEvent?.(input);
|
|
486
|
+
return respond(204, null);
|
|
487
|
+
}
|
|
488
|
+
return respond(404, { error: "not_found" });
|
|
489
|
+
};
|
|
490
|
+
}
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Small, dependency-free helpers shared by the browser and server entry points. */
|
|
2
|
+
export declare function base64UrlEncode(bytes: Uint8Array): string;
|
|
3
|
+
export declare function base64UrlDecode(value: string): Uint8Array<ArrayBuffer>;
|
|
4
|
+
export declare function randomString(bytes?: number): string;
|
|
5
|
+
export declare function sha256(value: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
6
|
+
export declare function toHex(bytes: Uint8Array): string;
|
|
7
|
+
export declare function utf8(value: string): Uint8Array<ArrayBuffer>;
|
|
8
|
+
export interface JwtParts {
|
|
9
|
+
header: Record<string, unknown>;
|
|
10
|
+
payload: Record<string, unknown>;
|
|
11
|
+
signingInput: string;
|
|
12
|
+
signature: Uint8Array<ArrayBuffer>;
|
|
13
|
+
}
|
|
14
|
+
export declare function decodeJwt(token: string): JwtParts;
|
|
15
|
+
export declare class NineError extends Error {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly status?: number | undefined;
|
|
18
|
+
constructor(code: string, message?: string, status?: number | undefined);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validates an issuer URL and drops a trailing slash. Issuers must use https; plain http
|
|
22
|
+
* is accepted only for localhost, *.localhost and loopback addresses (development), or
|
|
23
|
+
* when `allowInsecure` is set. Query strings, fragments and credentials are rejected.
|
|
24
|
+
* Throws `NineError` (`invalid_issuer` / `insecure_issuer`).
|
|
25
|
+
*/
|
|
26
|
+
export declare function normalizeIssuer(issuer: string, allowInsecure?: boolean): string;
|
|
27
|
+
export interface Discovery {
|
|
28
|
+
issuer: string;
|
|
29
|
+
authorization_endpoint: string;
|
|
30
|
+
token_endpoint: string;
|
|
31
|
+
userinfo_endpoint: string;
|
|
32
|
+
jwks_uri: string;
|
|
33
|
+
end_session_endpoint?: string;
|
|
34
|
+
revocation_endpoint?: string;
|
|
35
|
+
introspection_endpoint?: string;
|
|
36
|
+
device_authorization_endpoint?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function discover(issuer: string, fetcher?: typeof fetch): Promise<Discovery>;
|
|
39
|
+
export declare function postForm<T>(url: string, form: Record<string, string>, headers?: Record<string, string>, fetcher?: typeof fetch): Promise<T>;
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Small, dependency-free helpers shared by the browser and server entry points. */
|
|
2
|
+
const enc = new TextEncoder();
|
|
3
|
+
export function base64UrlEncode(bytes) {
|
|
4
|
+
let binary = "";
|
|
5
|
+
for (const b of bytes)
|
|
6
|
+
binary += String.fromCharCode(b);
|
|
7
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
export function base64UrlDecode(value) {
|
|
10
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);
|
|
11
|
+
const binary = atob(padded);
|
|
12
|
+
const out = new Uint8Array(binary.length);
|
|
13
|
+
for (let i = 0; i < binary.length; i++)
|
|
14
|
+
out[i] = binary.charCodeAt(i);
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
export function randomString(bytes = 32) {
|
|
18
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
19
|
+
}
|
|
20
|
+
export async function sha256(value) {
|
|
21
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(value)));
|
|
22
|
+
}
|
|
23
|
+
export function toHex(bytes) {
|
|
24
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
}
|
|
26
|
+
export function utf8(value) {
|
|
27
|
+
return enc.encode(value);
|
|
28
|
+
}
|
|
29
|
+
export function decodeJwt(token) {
|
|
30
|
+
const parts = token.split(".");
|
|
31
|
+
if (parts.length !== 3)
|
|
32
|
+
throw new NineError("invalid_token", "not a JWT");
|
|
33
|
+
const json = (s) => JSON.parse(new TextDecoder().decode(base64UrlDecode(s)));
|
|
34
|
+
return { header: json(parts[0]), payload: json(parts[1]), signingInput: `${parts[0]}.${parts[1]}`, signature: base64UrlDecode(parts[2]) };
|
|
35
|
+
}
|
|
36
|
+
export class NineError extends Error {
|
|
37
|
+
code;
|
|
38
|
+
status;
|
|
39
|
+
constructor(code, message, status) {
|
|
40
|
+
super(message ? `${code}: ${message}` : code);
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.status = status;
|
|
43
|
+
this.name = "NineError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isLocalHostname(hostname) {
|
|
47
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
48
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127(\.\d{1,3}){3}$/.test(host);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validates an issuer URL and drops a trailing slash. Issuers must use https; plain http
|
|
52
|
+
* is accepted only for localhost, *.localhost and loopback addresses (development), or
|
|
53
|
+
* when `allowInsecure` is set. Query strings, fragments and credentials are rejected.
|
|
54
|
+
* Throws `NineError` (`invalid_issuer` / `insecure_issuer`).
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeIssuer(issuer, allowInsecure = false) {
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(issuer);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new NineError("invalid_issuer", `not a URL: ${issuer}`);
|
|
63
|
+
}
|
|
64
|
+
if (url.search || url.hash || url.username || url.password)
|
|
65
|
+
throw new NineError("invalid_issuer", "an issuer has no query, fragment or credentials");
|
|
66
|
+
const secure = url.protocol === "https:" || (url.protocol === "http:" && (allowInsecure || isLocalHostname(url.hostname)));
|
|
67
|
+
if (!secure)
|
|
68
|
+
throw new NineError("insecure_issuer", `issuer must use https (got ${url.protocol}//${url.host}); plain http is only accepted for localhost`);
|
|
69
|
+
return issuer.replace(/\/$/, "");
|
|
70
|
+
}
|
|
71
|
+
const discoveryCache = new Map();
|
|
72
|
+
export function discover(issuer, fetcher = fetch) {
|
|
73
|
+
const key = issuer.replace(/\/$/, "");
|
|
74
|
+
let hit = discoveryCache.get(key);
|
|
75
|
+
if (!hit) {
|
|
76
|
+
hit = fetcher(`${key}/.well-known/openid-configuration`).then(async (res) => {
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
throw new NineError("discovery_failed", `HTTP ${res.status}`, res.status);
|
|
79
|
+
return (await res.json());
|
|
80
|
+
});
|
|
81
|
+
hit.catch(() => discoveryCache.delete(key));
|
|
82
|
+
discoveryCache.set(key, hit);
|
|
83
|
+
}
|
|
84
|
+
return hit;
|
|
85
|
+
}
|
|
86
|
+
export async function postForm(url, form, headers = {}, fetcher = fetch) {
|
|
87
|
+
const res = await fetcher(url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
|
|
90
|
+
body: new URLSearchParams(form),
|
|
91
|
+
});
|
|
92
|
+
const json = (await res.json().catch(() => ({})));
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
throw new NineError(String(json.error ?? "request_failed"), json.error_description ? String(json.error_description) : `HTTP ${res.status}`, res.status);
|
|
95
|
+
return json;
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@9in-io/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official 9in SDK: browser sign-in with PKCE, token verification, Management API client and adapter handler. Zero dependencies; runs in browsers, Node 18+, Cloudflare Workers, Deno and Bun.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"@9in/source": "./src/index.ts",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./server": {
|
|
17
|
+
"@9in/source": "./src/server.ts",
|
|
18
|
+
"types": "./dist/server.d.ts",
|
|
19
|
+
"default": "./dist/server.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"oauth",
|
|
32
|
+
"oidc",
|
|
33
|
+
"openid-connect",
|
|
34
|
+
"authentication",
|
|
35
|
+
"sso",
|
|
36
|
+
"pkce",
|
|
37
|
+
"jwt",
|
|
38
|
+
"9in"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
42
|
+
"prepack": "npm run build",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@9in/core": "*",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vitest": "^5.1.0"
|
|
50
|
+
}
|
|
51
|
+
}
|