@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/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
+ }
package/src/web-app.ts ADDED
@@ -0,0 +1,207 @@
1
+ import { NineError, base64UrlEncode, discover, normalizeIssuer, postForm, randomString, sha256 } from "./util.js";
2
+ import { verifyIdToken, type IdTokenClaims } from "./server.js";
3
+
4
+ /**
5
+ * Sign-in for server-rendered web apps (authorization code + PKCE, with or without a client
6
+ * secret), using only `fetch` and Web Crypto: it runs on Node 18+, Next.js (Node and Edge),
7
+ * Cloudflare Workers, Deno and Bun. Framework packages keep the transaction and the session
8
+ * (for example in encrypted cookies); this class does the protocol:
9
+ *
10
+ * const { url, transaction } = await auth.createLogin({ returnTo: "/dashboard" });
11
+ * // keep `transaction` until the callback, redirect the browser to `url`
12
+ * const { tokens, claims, returnTo } = await auth.handleCallback(request.url, transaction);
13
+ */
14
+
15
+ export interface WebAppAuthOptions {
16
+ /** Your tenant's issuer URL, e.g. https://acme.9in.io. */
17
+ issuer: string;
18
+ clientId: string;
19
+ /** For confidential applications: sent with HTTP Basic. Leave out for public ones (PKCE only). */
20
+ clientSecret?: string;
21
+ /** Your callback URL, as registered on the application. */
22
+ redirectUri: string;
23
+ /** Default "openid profile email offline_access" (offline_access gives a refresh token). */
24
+ scope?: string;
25
+ /** An API identifier, to get access tokens for that API. */
26
+ audience?: string;
27
+ /** Only for tenants that sign with a shared (HMAC) key. */
28
+ idTokenSecret?: string;
29
+ fetch?: typeof fetch;
30
+ /** Allows http:// issuers other than localhost (never in production). */
31
+ allowInsecureIssuer?: boolean;
32
+ }
33
+
34
+ export interface WebLoginOptions {
35
+ /** A path in your app to come back to after sign-in. */
36
+ returnTo?: string;
37
+ prompt?: "login" | "consent" | "none" | "select_account" | "create";
38
+ /** Prefills the identifier on the sign-in page. */
39
+ loginHint?: string;
40
+ /** "signup" opens the registration page. */
41
+ screen?: "login" | "signup";
42
+ /** Language of the hosted pages (ui_locales), e.g. "de". */
43
+ locale?: string;
44
+ /** Extra authorization parameters (they never replace state, nonce or PKCE). */
45
+ params?: Record<string, string>;
46
+ }
47
+
48
+ /** What to keep between the redirect to 9in and the callback (store it encrypted; it's secret). */
49
+ export interface LoginTransaction {
50
+ state: string;
51
+ nonce: string;
52
+ verifier: string;
53
+ returnTo?: string;
54
+ /** ms */
55
+ createdAt: number;
56
+ }
57
+
58
+ export interface WebTokenSet {
59
+ accessToken: string;
60
+ idToken: string | null;
61
+ refreshToken: string | null;
62
+ /** ms */
63
+ expiresAt: number;
64
+ scope: string;
65
+ tokenType: string;
66
+ }
67
+
68
+ export interface CallbackResult {
69
+ tokens: WebTokenSet;
70
+ /** The verified ID token's claims: the signed-in user. */
71
+ claims: IdTokenClaims;
72
+ returnTo?: string;
73
+ }
74
+
75
+ const TRANSACTION_TTL = 15 * 60_000;
76
+ const RESERVED = new Set(["response_type", "client_id", "redirect_uri", "state", "nonce", "code_challenge", "code_challenge_method", "scope"]);
77
+
78
+ interface TokenResponse {
79
+ access_token: string;
80
+ id_token?: string;
81
+ refresh_token?: string;
82
+ expires_in?: number;
83
+ scope?: string;
84
+ token_type?: string;
85
+ }
86
+
87
+ export class WebAppAuth {
88
+ private readonly issuer: string;
89
+ private readonly fetcher: typeof fetch;
90
+
91
+ constructor(private readonly options: WebAppAuthOptions) {
92
+ if (!options.clientId) throw new NineError("invalid_options", "clientId is required");
93
+ if (!options.redirectUri) throw new NineError("invalid_options", "redirectUri is required");
94
+ this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
95
+ this.fetcher = options.fetch ?? fetch.bind(globalThis);
96
+ }
97
+
98
+ /** The URL to send the browser to, and the transaction to keep until the callback. */
99
+ async createLogin(options: WebLoginOptions = {}): Promise<{ url: string; transaction: LoginTransaction }> {
100
+ const config = await discover(this.issuer, this.fetcher);
101
+ const transaction: LoginTransaction = {
102
+ state: randomString(24),
103
+ nonce: randomString(24),
104
+ verifier: randomString(48),
105
+ createdAt: Date.now(),
106
+ ...(options.returnTo ? { returnTo: options.returnTo } : {}),
107
+ };
108
+ const url = new URL(config.authorization_endpoint);
109
+ const params = url.searchParams;
110
+ for (const [key, value] of Object.entries(options.params ?? {})) if (!RESERVED.has(key)) params.set(key, value);
111
+ params.set("response_type", "code");
112
+ params.set("client_id", this.options.clientId);
113
+ params.set("redirect_uri", this.options.redirectUri);
114
+ params.set("scope", this.options.scope ?? "openid profile email offline_access");
115
+ params.set("state", transaction.state);
116
+ params.set("nonce", transaction.nonce);
117
+ params.set("code_challenge", base64UrlEncode(await sha256(transaction.verifier)));
118
+ params.set("code_challenge_method", "S256");
119
+ if (this.options.audience) params.set("audience", this.options.audience);
120
+ const prompt = options.screen === "signup" ? "create" : options.prompt;
121
+ if (prompt) params.set("prompt", prompt);
122
+ if (options.loginHint) params.set("login_hint", options.loginHint);
123
+ if (options.locale) params.set("ui_locales", options.locale);
124
+ return { url: url.toString(), transaction };
125
+ }
126
+
127
+ /**
128
+ * Finishes sign-in on your callback URL: checks the state against the transaction, exchanges
129
+ * the code and verifies the ID token (signature, issuer, audience, expiry, nonce).
130
+ */
131
+ async handleCallback(callbackUrl: string | URL, transaction: LoginTransaction | null | undefined): Promise<CallbackResult> {
132
+ const url = new URL(callbackUrl);
133
+ const error = url.searchParams.get("error");
134
+ if (error) throw new NineError(error, url.searchParams.get("error_description") ?? error, 400);
135
+ if (!transaction) throw new NineError("invalid_state", "no sign-in in progress (it expired, or started in another browser)", 400);
136
+ if (Date.now() - transaction.createdAt > TRANSACTION_TTL) throw new NineError("invalid_state", "the sign-in took too long; start again", 400);
137
+ if (url.searchParams.get("state") !== transaction.state) throw new NineError("invalid_state", "state mismatch", 400);
138
+ // RFC 9207: when the issuer says who it is, it must be ours.
139
+ const iss = url.searchParams.get("iss");
140
+ if (iss && iss.replace(/\/$/, "") !== this.issuer) throw new NineError("invalid_issuer", `unexpected issuer ${iss}`, 400);
141
+ const code = url.searchParams.get("code");
142
+ if (!code) throw new NineError("missing_code", "no authorization code in the URL", 400);
143
+ const tokens = await this.tokenRequest({ grant_type: "authorization_code", code, redirect_uri: this.options.redirectUri, code_verifier: transaction.verifier });
144
+ const claims = await verifyIdToken(tokens.idToken, {
145
+ issuer: this.issuer,
146
+ clientId: this.options.clientId,
147
+ nonce: transaction.nonce,
148
+ secret: this.options.idTokenSecret,
149
+ fetch: this.fetcher,
150
+ allowInsecureIssuer: this.options.allowInsecureIssuer,
151
+ });
152
+ return { tokens, claims, ...(transaction.returnTo ? { returnTo: transaction.returnTo } : {}) };
153
+ }
154
+
155
+ /** New tokens from a refresh token (9in rotates it: keep the one returned). */
156
+ async refresh(refreshToken: string): Promise<WebTokenSet> {
157
+ const tokens = await this.tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken });
158
+ return { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken };
159
+ }
160
+
161
+ /** The URL that ends the session at 9in and comes back to `postLogoutRedirectUri`. */
162
+ async logoutUrl(options: { idTokenHint?: string | null; postLogoutRedirectUri?: string; state?: string } = {}): Promise<string> {
163
+ const config = await discover(this.issuer, this.fetcher);
164
+ const url = new URL(config.end_session_endpoint ?? `${this.issuer}/logout`);
165
+ url.searchParams.set("client_id", this.options.clientId);
166
+ if (options.idTokenHint) url.searchParams.set("id_token_hint", options.idTokenHint);
167
+ if (options.postLogoutRedirectUri) url.searchParams.set("post_logout_redirect_uri", options.postLogoutRedirectUri);
168
+ if (options.state) url.searchParams.set("state", options.state);
169
+ return url.toString();
170
+ }
171
+
172
+ /** Revokes a refresh token (ending the session's token family) or an access token. */
173
+ async revoke(token: string, hint: "refresh_token" | "access_token" = "refresh_token"): Promise<void> {
174
+ const config = await discover(this.issuer, this.fetcher);
175
+ if (!config.revocation_endpoint) return;
176
+ const { form, headers } = this.clientAuth({ token, token_type_hint: hint });
177
+ await postForm(config.revocation_endpoint, form, headers, this.fetcher);
178
+ }
179
+
180
+ /** The user's current profile from the userinfo endpoint. */
181
+ async userInfo(accessToken: string): Promise<Record<string, unknown>> {
182
+ const config = await discover(this.issuer, this.fetcher);
183
+ const res = await this.fetcher(config.userinfo_endpoint, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" } });
184
+ if (!res.ok) throw new NineError(res.status === 401 ? "invalid_token" : "userinfo_failed", `HTTP ${res.status}`, res.status);
185
+ return (await res.json()) as Record<string, unknown>;
186
+ }
187
+
188
+ private clientAuth(form: Record<string, string>): { form: Record<string, string>; headers: Record<string, string> } {
189
+ if (!this.options.clientSecret) return { form: { ...form, client_id: this.options.clientId }, headers: {} };
190
+ const basic = btoa(`${encodeURIComponent(this.options.clientId)}:${encodeURIComponent(this.options.clientSecret)}`);
191
+ return { form, headers: { Authorization: `Basic ${basic}` } };
192
+ }
193
+
194
+ private async tokenRequest(input: Record<string, string>): Promise<WebTokenSet> {
195
+ const config = await discover(this.issuer, this.fetcher);
196
+ const { form, headers } = this.clientAuth(input);
197
+ const json = await postForm<TokenResponse>(config.token_endpoint, form, headers, this.fetcher);
198
+ return {
199
+ accessToken: json.access_token,
200
+ idToken: json.id_token ?? null,
201
+ refreshToken: json.refresh_token ?? null,
202
+ expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000,
203
+ scope: json.scope ?? "",
204
+ tokenType: json.token_type ?? "Bearer",
205
+ };
206
+ }
207
+ }