@9in-io/sdk 0.1.1 → 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/index.ts ADDED
@@ -0,0 +1,325 @@
1
+ import { NineError, base64UrlEncode, decodeJwt, discover, normalizeIssuer, postForm, randomString, sha256 } from "./util.js";
2
+
3
+ export { NineError, normalizeIssuer } from "./util.js";
4
+
5
+ /**
6
+ * Browser sign-in for single-page apps (also works in React Native / Electron with a
7
+ * custom `storage` and `navigate`). Authorization code flow with PKCE, automatic
8
+ * refresh-token rotation, RP-initiated logout.
9
+ *
10
+ * const auth = new NineAuth({ issuer, clientId, redirectUri });
11
+ * await auth.login(); // redirect to the hosted login
12
+ * await auth.handleRedirect(); // on the callback page
13
+ * const user = await auth.getUser();
14
+ * const token = await auth.getAccessToken();
15
+ */
16
+
17
+ export interface StorageLike {
18
+ getItem(key: string): string | null;
19
+ setItem(key: string, value: string): void;
20
+ removeItem(key: string): void;
21
+ }
22
+
23
+ export interface NineAuthOptions {
24
+ /** Your tenant's issuer, e.g. https://acme.9in.io or https://login.acme.com */
25
+ issuer: string;
26
+ clientId: string;
27
+ redirectUri: string;
28
+ /** Default: "openid profile email offline_access". */
29
+ scope?: string;
30
+ /** API identifier to request access tokens for (aud claim). */
31
+ audience?: string;
32
+ /** Where tokens are kept. Default: localStorage (falls back to memory). */
33
+ storage?: StorageLike;
34
+ /** Where the PKCE verifier and state are kept during the redirect. Default: sessionStorage. */
35
+ transientStorage?: StorageLike;
36
+ /** Navigation hook (default: window.location.assign). */
37
+ navigate?: (url: string) => void;
38
+ /** Custom fetch (tests, React Native). */
39
+ fetch?: typeof fetch;
40
+ /** Language of the hosted pages (ui_locales). */
41
+ locale?: string;
42
+ /** Accept a plain-http issuer other than localhost (development only). */
43
+ allowInsecureIssuer?: boolean;
44
+ }
45
+
46
+ export interface LoginOptions {
47
+ /** "login" forces re-authentication, "consent" re-asks for consent, "none" fails instead of showing UI. */
48
+ prompt?: "login" | "consent" | "none" | "select_account";
49
+ /** Pre-fill the identifier field. */
50
+ loginHint?: string;
51
+ /** Open the sign-up form directly (prompt=create). */
52
+ screen?: "login" | "signup";
53
+ /** Where to send the user after handleRedirect (returned to you). */
54
+ returnTo?: string;
55
+ /** Extra authorization parameters. */
56
+ params?: Record<string, string>;
57
+ }
58
+
59
+ export interface TokenSet {
60
+ accessToken: string;
61
+ idToken: string | null;
62
+ refreshToken: string | null;
63
+ /** ms since epoch */
64
+ expiresAt: number;
65
+ scope: string;
66
+ }
67
+
68
+ export interface User {
69
+ sub: string;
70
+ email?: string;
71
+ email_verified?: boolean;
72
+ name?: string;
73
+ given_name?: string;
74
+ family_name?: string;
75
+ picture?: string;
76
+ phone_number?: string;
77
+ locale?: string;
78
+ [claim: string]: unknown;
79
+ }
80
+
81
+ class MemoryStorage implements StorageLike {
82
+ private map = new Map<string, string>();
83
+ getItem(key: string) {
84
+ return this.map.get(key) ?? null;
85
+ }
86
+ setItem(key: string, value: string) {
87
+ this.map.set(key, value);
88
+ }
89
+ removeItem(key: string) {
90
+ this.map.delete(key);
91
+ }
92
+ }
93
+
94
+ function defaultStorage(kind: "localStorage" | "sessionStorage"): StorageLike {
95
+ try {
96
+ const store = (globalThis as unknown as Record<string, StorageLike | undefined>)[kind];
97
+ if (store) {
98
+ store.setItem("__9in", "1");
99
+ store.removeItem("__9in");
100
+ return store;
101
+ }
102
+ } catch {
103
+ // storage disabled (private mode, sandboxed iframe)
104
+ }
105
+ return new MemoryStorage();
106
+ }
107
+
108
+ interface PendingLogin {
109
+ state: string;
110
+ nonce: string;
111
+ verifier: string;
112
+ returnTo?: string;
113
+ }
114
+
115
+ export class NineAuth {
116
+ private readonly issuer: string;
117
+ private readonly storage: StorageLike;
118
+ private readonly transient: StorageLike;
119
+ private readonly fetcher: typeof fetch;
120
+ private readonly key: string;
121
+ private refreshing: Promise<TokenSet | null> | null = null;
122
+
123
+ constructor(private readonly options: NineAuthOptions) {
124
+ if (!options.issuer || !options.clientId || !options.redirectUri) throw new NineError("invalid_options", "issuer, clientId and redirectUri are required");
125
+ this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
126
+ this.storage = options.storage ?? defaultStorage("localStorage");
127
+ this.transient = options.transientStorage ?? defaultStorage("sessionStorage");
128
+ this.fetcher = options.fetch ?? fetch.bind(globalThis);
129
+ this.key = `9in:${this.issuer}:${options.clientId}`;
130
+ }
131
+
132
+ private navigate(url: string) {
133
+ if (this.options.navigate) this.options.navigate(url);
134
+ else window.location.assign(url);
135
+ }
136
+
137
+ /** Builds the authorization URL and stores the PKCE verifier; `login()` navigates to it. */
138
+ async buildLoginUrl(options: LoginOptions = {}): Promise<string> {
139
+ const config = await discover(this.issuer, this.fetcher);
140
+ const pending: PendingLogin = { state: randomString(16), nonce: randomString(16), verifier: randomString(48), returnTo: options.returnTo };
141
+ this.transient.setItem(`${this.key}:pending`, JSON.stringify(pending));
142
+ const url = new URL(config.authorization_endpoint);
143
+ const params: Record<string, string> = {
144
+ response_type: "code",
145
+ client_id: this.options.clientId,
146
+ redirect_uri: this.options.redirectUri,
147
+ scope: this.options.scope ?? "openid profile email offline_access",
148
+ state: pending.state,
149
+ nonce: pending.nonce,
150
+ code_challenge: base64UrlEncode(await sha256(pending.verifier)),
151
+ code_challenge_method: "S256",
152
+ ...(this.options.audience ? { audience: this.options.audience } : {}),
153
+ ...(this.options.locale ? { ui_locales: this.options.locale } : {}),
154
+ ...(options.loginHint ? { login_hint: options.loginHint } : {}),
155
+ ...(options.screen === "signup" ? { prompt: "create" } : options.prompt ? { prompt: options.prompt } : {}),
156
+ ...options.params,
157
+ };
158
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
159
+ return url.toString();
160
+ }
161
+
162
+ /** Redirects to the hosted login page. */
163
+ async login(options: LoginOptions = {}): Promise<void> {
164
+ this.navigate(await this.buildLoginUrl(options));
165
+ }
166
+
167
+ /** Opens the sign-up form directly. */
168
+ async signup(options: Omit<LoginOptions, "screen"> = {}): Promise<void> {
169
+ return this.login({ ...options, screen: "signup" });
170
+ }
171
+
172
+ /**
173
+ * Completes sign-in on the callback page: checks state, exchanges the code with the PKCE
174
+ * verifier and stores the tokens. Removes the code from the address bar.
175
+ */
176
+ async handleRedirect(url: string = typeof window !== "undefined" ? window.location.href : ""): Promise<{ user: User; returnTo?: string }> {
177
+ const current = new URL(url);
178
+ const params = current.searchParams;
179
+ const raw = this.transient.getItem(`${this.key}:pending`);
180
+ this.transient.removeItem(`${this.key}:pending`);
181
+ if (params.get("error")) throw new NineError(params.get("error")!, params.get("error_description") ?? undefined);
182
+ const code = params.get("code");
183
+ if (!code) throw new NineError("missing_code", "no authorization code in the URL");
184
+ if (!raw) throw new NineError("invalid_state", "no login in progress (opened in another tab?)");
185
+ const pending = JSON.parse(raw) as PendingLogin;
186
+ if (params.get("state") !== pending.state) throw new NineError("invalid_state", "state mismatch");
187
+ const iss = params.get("iss");
188
+ if (iss && iss.replace(/\/$/, "") !== this.issuer && !(await this.sameIssuer(iss))) throw new NineError("invalid_issuer", `unexpected issuer ${iss}`);
189
+
190
+ const config = await discover(this.issuer, this.fetcher);
191
+ const tokens = await postForm<Record<string, unknown>>(
192
+ config.token_endpoint,
193
+ { grant_type: "authorization_code", code, redirect_uri: this.options.redirectUri, client_id: this.options.clientId, code_verifier: pending.verifier },
194
+ {},
195
+ this.fetcher,
196
+ );
197
+ if (typeof tokens.id_token === "string") {
198
+ // The ID token came straight from the token endpoint over TLS (OIDC Core 3.1.3.7):
199
+ // its claims are checked; the signature check is for servers (verifyAccessToken).
200
+ // Checked before anything is stored, so a refused sign-in leaves no tokens behind.
201
+ let payload: Record<string, unknown>;
202
+ try {
203
+ payload = decodeJwt(tokens.id_token).payload;
204
+ } catch {
205
+ throw new NineError("invalid_id_token", "malformed ID token");
206
+ }
207
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
208
+ if (!aud.includes(this.options.clientId)) throw new NineError("invalid_id_token", "audience mismatch");
209
+ if (payload.nonce !== pending.nonce) throw new NineError("invalid_id_token", "nonce mismatch");
210
+ }
211
+ this.store(tokens);
212
+ if (typeof window !== "undefined" && window.history && url === window.location.href) {
213
+ for (const p of ["code", "state", "iss", "session_state"]) current.searchParams.delete(p);
214
+ window.history.replaceState(window.history.state, "", current.pathname + current.search + current.hash);
215
+ }
216
+ return { user: (await this.getUser())!, returnTo: pending.returnTo };
217
+ }
218
+
219
+ private async sameIssuer(iss: string): Promise<boolean> {
220
+ const config = await discover(this.issuer, this.fetcher);
221
+ return config.issuer.replace(/\/$/, "") === iss.replace(/\/$/, "");
222
+ }
223
+
224
+ private store(tokens: Record<string, unknown>, previous?: TokenSet | null): TokenSet {
225
+ const set: TokenSet = {
226
+ accessToken: String(tokens.access_token),
227
+ idToken: typeof tokens.id_token === "string" ? tokens.id_token : (previous?.idToken ?? null),
228
+ refreshToken: typeof tokens.refresh_token === "string" ? tokens.refresh_token : (previous?.refreshToken ?? null),
229
+ expiresAt: Date.now() + Number(tokens.expires_in ?? 3600) * 1000,
230
+ scope: String(tokens.scope ?? previous?.scope ?? ""),
231
+ };
232
+ this.storage.setItem(`${this.key}:tokens`, JSON.stringify(set));
233
+ return set;
234
+ }
235
+
236
+ /** Stored tokens (possibly expired). */
237
+ getTokens(): TokenSet | null {
238
+ const raw = this.storage.getItem(`${this.key}:tokens`);
239
+ if (!raw) return null;
240
+ try {
241
+ return JSON.parse(raw) as TokenSet;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /** True when there is a usable access token or a refresh token. */
248
+ isAuthenticated(): boolean {
249
+ const t = this.getTokens();
250
+ return !!t && (t.expiresAt > Date.now() || !!t.refreshToken);
251
+ }
252
+
253
+ /** Claims of the signed-in user from the ID token, or null. */
254
+ async getUser(): Promise<User | null> {
255
+ const t = this.getTokens();
256
+ if (!t?.idToken) return null;
257
+ return decodeJwt(t.idToken).payload as User;
258
+ }
259
+
260
+ /** Fresh claims from the userinfo endpoint. */
261
+ async fetchUserInfo(): Promise<User> {
262
+ const config = await discover(this.issuer, this.fetcher);
263
+ const res = await this.fetcher(config.userinfo_endpoint, { headers: { Authorization: `Bearer ${await this.getAccessToken()}` } });
264
+ if (!res.ok) throw new NineError("userinfo_failed", `HTTP ${res.status}`, res.status);
265
+ return (await res.json()) as User;
266
+ }
267
+
268
+ /**
269
+ * A valid access token, refreshed (with rotation) when it expires within a minute.
270
+ * Concurrent callers share one refresh. Throws `login_required` when signed out.
271
+ */
272
+ async getAccessToken(): Promise<string> {
273
+ const t = this.getTokens();
274
+ if (t && t.expiresAt - 60_000 > Date.now()) return t.accessToken;
275
+ if (!t?.refreshToken) throw new NineError("login_required");
276
+ this.refreshing ??= this.refresh(t).finally(() => {
277
+ this.refreshing = null;
278
+ });
279
+ const next = await this.refreshing;
280
+ if (!next) throw new NineError("login_required");
281
+ return next.accessToken;
282
+ }
283
+
284
+ private async refresh(current: TokenSet): Promise<TokenSet | null> {
285
+ const config = await discover(this.issuer, this.fetcher);
286
+ try {
287
+ const tokens = await postForm<Record<string, unknown>>(
288
+ config.token_endpoint,
289
+ { grant_type: "refresh_token", refresh_token: current.refreshToken!, client_id: this.options.clientId },
290
+ {},
291
+ this.fetcher,
292
+ );
293
+ return this.store(tokens, current);
294
+ } catch (error) {
295
+ if (error instanceof NineError && error.code === "invalid_grant") {
296
+ this.storage.removeItem(`${this.key}:tokens`);
297
+ return null;
298
+ }
299
+ throw error;
300
+ }
301
+ }
302
+
303
+ /** fetch() with the access token attached. */
304
+ async fetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
305
+ const headers = new Headers(init.headers);
306
+ headers.set("Authorization", `Bearer ${await this.getAccessToken()}`);
307
+ return this.fetcher(input, { ...init, headers });
308
+ }
309
+
310
+ /** Clears local tokens, revokes the refresh token and ends the 9in session. */
311
+ async logout(options: { returnTo?: string; local?: boolean } = {}): Promise<void> {
312
+ const t = this.getTokens();
313
+ this.storage.removeItem(`${this.key}:tokens`);
314
+ const config = await discover(this.issuer, this.fetcher);
315
+ if (t?.refreshToken && config.revocation_endpoint) {
316
+ await postForm(config.revocation_endpoint, { token: t.refreshToken, token_type_hint: "refresh_token", client_id: this.options.clientId }, {}, this.fetcher).catch(() => undefined);
317
+ }
318
+ if (options.local) return;
319
+ const url = new URL(config.end_session_endpoint ?? `${this.issuer}/oauth/logout`);
320
+ url.searchParams.set("client_id", this.options.clientId);
321
+ if (t?.idToken) url.searchParams.set("id_token_hint", t.idToken);
322
+ if (options.returnTo) url.searchParams.set("post_logout_redirect_uri", options.returnTo);
323
+ this.navigate(url.toString());
324
+ }
325
+ }