@9in-io/sdk 0.1.0 → 0.2.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/src/util.ts ADDED
@@ -0,0 +1,121 @@
1
+ /** Small, dependency-free helpers shared by the browser and server entry points. */
2
+
3
+ const enc = new TextEncoder();
4
+
5
+ export function base64UrlEncode(bytes: Uint8Array): string {
6
+ let binary = "";
7
+ for (const b of bytes) binary += String.fromCharCode(b);
8
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+
11
+ export function base64UrlDecode(value: string): Uint8Array<ArrayBuffer> {
12
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);
13
+ const binary = atob(padded);
14
+ const out = new Uint8Array(binary.length);
15
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
16
+ return out;
17
+ }
18
+
19
+ export function randomString(bytes = 32): string {
20
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(bytes)));
21
+ }
22
+
23
+ export async function sha256(value: string): Promise<Uint8Array<ArrayBuffer>> {
24
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(value)));
25
+ }
26
+
27
+ export function toHex(bytes: Uint8Array): string {
28
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
29
+ }
30
+
31
+ export function utf8(value: string): Uint8Array<ArrayBuffer> {
32
+ return enc.encode(value) as Uint8Array<ArrayBuffer>;
33
+ }
34
+
35
+ export interface JwtParts {
36
+ header: Record<string, unknown>;
37
+ payload: Record<string, unknown>;
38
+ signingInput: string;
39
+ signature: Uint8Array<ArrayBuffer>;
40
+ }
41
+
42
+ export function decodeJwt(token: string): JwtParts {
43
+ const parts = token.split(".");
44
+ if (parts.length !== 3) throw new NineError("invalid_token", "not a JWT");
45
+ const json = (s: string) => JSON.parse(new TextDecoder().decode(base64UrlDecode(s))) as Record<string, unknown>;
46
+ return { header: json(parts[0]!), payload: json(parts[1]!), signingInput: `${parts[0]}.${parts[1]}`, signature: base64UrlDecode(parts[2]!) };
47
+ }
48
+
49
+ export class NineError extends Error {
50
+ constructor(
51
+ readonly code: string,
52
+ message?: string,
53
+ readonly status?: number,
54
+ ) {
55
+ super(message ? `${code}: ${message}` : code);
56
+ this.name = "NineError";
57
+ }
58
+ }
59
+
60
+ function isLocalHostname(hostname: string): boolean {
61
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
62
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127(\.\d{1,3}){3}$/.test(host);
63
+ }
64
+
65
+ /**
66
+ * Validates an issuer URL and drops a trailing slash. Issuers must use https; plain http
67
+ * is accepted only for localhost, *.localhost and loopback addresses (development), or
68
+ * when `allowInsecure` is set. Query strings, fragments and credentials are rejected.
69
+ * Throws `NineError` (`invalid_issuer` / `insecure_issuer`).
70
+ */
71
+ export function normalizeIssuer(issuer: string, allowInsecure = false): string {
72
+ let url: URL;
73
+ try {
74
+ url = new URL(issuer);
75
+ } catch {
76
+ throw new NineError("invalid_issuer", `not a URL: ${issuer}`);
77
+ }
78
+ if (url.search || url.hash || url.username || url.password) throw new NineError("invalid_issuer", "an issuer has no query, fragment or credentials");
79
+ const secure = url.protocol === "https:" || (url.protocol === "http:" && (allowInsecure || isLocalHostname(url.hostname)));
80
+ if (!secure) throw new NineError("insecure_issuer", `issuer must use https (got ${url.protocol}//${url.host}); plain http is only accepted for localhost`);
81
+ return issuer.replace(/\/$/, "");
82
+ }
83
+
84
+ const discoveryCache = new Map<string, Promise<Discovery>>();
85
+
86
+ export interface Discovery {
87
+ issuer: string;
88
+ authorization_endpoint: string;
89
+ token_endpoint: string;
90
+ userinfo_endpoint: string;
91
+ jwks_uri: string;
92
+ end_session_endpoint?: string;
93
+ revocation_endpoint?: string;
94
+ introspection_endpoint?: string;
95
+ device_authorization_endpoint?: string;
96
+ }
97
+
98
+ export function discover(issuer: string, fetcher: typeof fetch = fetch): Promise<Discovery> {
99
+ const key = issuer.replace(/\/$/, "");
100
+ let hit = discoveryCache.get(key);
101
+ if (!hit) {
102
+ hit = fetcher(`${key}/.well-known/openid-configuration`).then(async (res) => {
103
+ if (!res.ok) throw new NineError("discovery_failed", `HTTP ${res.status}`, res.status);
104
+ return (await res.json()) as Discovery;
105
+ });
106
+ hit.catch(() => discoveryCache.delete(key));
107
+ discoveryCache.set(key, hit);
108
+ }
109
+ return hit;
110
+ }
111
+
112
+ export async function postForm<T>(url: string, form: Record<string, string>, headers: Record<string, string> = {}, fetcher: typeof fetch = fetch): Promise<T> {
113
+ const res = await fetcher(url, {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...headers },
116
+ body: new URLSearchParams(form),
117
+ });
118
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
119
+ if (!res.ok) throw new NineError(String(json.error ?? "request_failed"), json.error_description ? String(json.error_description) : `HTTP ${res.status}`, res.status);
120
+ return json as T;
121
+ }