@9in-io/sdk 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,3 +1,186 @@
1
- # Temporary Holding Version
1
+ # @9in-io/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official SDK for [9in](https://9in.io). Zero dependencies; runs in browsers, Node 18+,
4
+ Cloudflare Workers, Deno and Bun.
5
+
6
+ ```bash
7
+ npm install @9in-io/sdk
8
+ ```
9
+
10
+ ## Sign users in (browser)
11
+
12
+ ```ts
13
+ import { NineAuth } from "@9in-io/sdk";
14
+
15
+ const auth = new NineAuth({
16
+ issuer: "https://acme.9in.io", // or your custom domain
17
+ clientId: "app_7f3k2m9q",
18
+ redirectUri: `${location.origin}/callback`,
19
+ audience: "https://api.acme.com", // optional: access tokens for your API
20
+ });
21
+
22
+ await auth.login(); // authorization code + PKCE
23
+ await auth.signup(); // opens the sign-up form (prompt=create)
24
+
25
+ // on /callback
26
+ const { user, returnTo } = await auth.handleRedirect();
27
+
28
+ const token = await auth.getAccessToken(); // refreshed (and rotated) automatically
29
+ const res = await auth.fetch("https://api.acme.com/orders");
30
+ await auth.logout({ returnTo: location.origin });
31
+ ```
32
+
33
+ ## Protect your API (server)
34
+
35
+ ```ts
36
+ import { verifyAccessToken } from "@9in-io/sdk/server";
37
+
38
+ const claims = await verifyAccessToken(bearer, {
39
+ issuer: "https://acme.9in.io",
40
+ audience: "https://api.acme.com",
41
+ scope: "orders:read",
42
+ });
43
+ ```
44
+
45
+ Tokens are verified locally against your tenant's JWKS (cached; refetched once when a
46
+ new key appears after rotation). Every algorithm of the 9in key library is supported:
47
+ RS256/384/512, PS256/384/512, ES256, ES384 and EdDSA through JWKS, and HS256/384/512 when
48
+ your API uses a shared secret (`secret: process.env.NINE_API_SECRET`). The key's own
49
+ algorithm is enforced, and only access tokens (`typ: at+jwt`) are accepted.
50
+
51
+ ### Revocation: hybrid verification
52
+
53
+ A local check cannot see revocations: a token revoked through `/oauth/revoke`, a sign-out
54
+ or a blocked user keeps passing until it expires. `createTokenVerifier` closes that gap:
55
+
56
+ ```ts
57
+ import { createTokenVerifier, verifyWebhook } from "@9in-io/sdk/server";
58
+
59
+ const verifier = createTokenVerifier({
60
+ issuer: "https://acme.9in.io",
61
+ audience: "https://api.acme.com",
62
+ clientId: process.env.NINE_CLIENT_ID, // a confidential application
63
+ clientSecret: process.env.NINE_CLIENT_SECRET,
64
+ mode: "hybrid", // default
65
+ cacheTtl: 300, // seconds an answer is trusted without news
66
+ });
67
+
68
+ const claims = await verifier.verify(bearer, { scope: "orders:read" });
69
+
70
+ // Webhook endpoint subscribed to token.revoked, session.revoked and user.* events
71
+ const event = await verifyWebhook(process.env.NINE_WEBHOOK_SECRET, request);
72
+ verifier.handleEvent(event);
73
+ ```
74
+
75
+ | Mode | What it does |
76
+ |---|---|
77
+ | `local` | Signature and claims only. No calls to 9in. |
78
+ | `hybrid` | Local check, then one introspection per token. The answer is cached until a webhook says otherwise or `cacheTtl` passes; a refusal is cached until the token expires. |
79
+ | `introspect` | Introspection on every call. |
80
+
81
+ Opaque access tokens (`9in_at_…`, enabled per tenant or per API) are always introspected,
82
+ and cached in hybrid mode. The cache is in memory, per process. Each process needs the
83
+ webhook events, or a short `cacheTtl`.
84
+
85
+ ## Manage users (server)
86
+
87
+ ```ts
88
+ import { ManagementClient } from "@9in-io/sdk/server";
89
+
90
+ const nine = new ManagementClient({ issuer, clientId, clientSecret }); // app with management scopes
91
+ await nine.createUser({ email: "jane@acme.com", password: "…", emailVerified: true });
92
+ await nine.blockUser(userId);
93
+ const { user } = await nine.findUser("phone", "+447700900000"); // or "email", "username"
94
+
95
+ // Sessions: list them, end one (its sid), or sign the user out everywhere
96
+ const { sessions } = await nine.listUserSessions(userId);
97
+ await nine.revokeSession(sessions[0].sid);
98
+ await nine.revokeUserSessions(userId);
99
+
100
+ // Single-use sign-in link (magic links, moving a signed-in user from your app to 9in)
101
+ const { url } = await nine.createLoginLink(userId, { returnTo: "https://app.acme.com/callback", ttlSeconds: 600 });
102
+
103
+ // Bulk import (500 per call): old ids, password hashes (bcrypt, argon2, PBKDF2, Firebase scrypt), social identities
104
+ const { results } = await nine.importUsers([{ id: "auth0|42", email: "jane@acme.com", passwordHash: "$2b$10$…" }], { onConflict: "skip", source: "auth0" });
105
+ ```
106
+
107
+ ## Keep users in your own database (adapter)
108
+
109
+ ```ts
110
+ import { adapterHandler } from "@9in-io/sdk/server";
111
+
112
+ export default {
113
+ fetch: adapterHandler({
114
+ secret: env.NINE_ADAPTER_SECRET,
115
+ async authenticate({ type, value }, password) {
116
+ const user = await db.findByLogin(type, value);
117
+ if (!user) return "invalid";
118
+ if (user.disabled) return "blocked";
119
+ return (await verify(password, user.hash)) ? toAdapterUser(user) : "invalid";
120
+ },
121
+ async findUser(query) {
122
+ const user = "id" in query ? await db.findById(query.id) : await db.findByLogin(query.identifier.type, query.identifier.value);
123
+ return user ? toAdapterUser(user) : null;
124
+ },
125
+ async onEvent(event) {
126
+ if (event.type === "otp.requested") await sendCode(event.data);
127
+ },
128
+ }),
129
+ };
130
+ ```
131
+
132
+ Requests and responses are signed with HMAC-SHA256 (`9in-signature`), and each response
133
+ is bound to its request id.
134
+
135
+ ## Receive webhooks (server)
136
+
137
+ Add an endpoint under **Advanced → Webhooks** in the console and keep the signing secret it
138
+ shows once. `verifyWebhook` checks the `9in-signature` header against the raw body (and a
139
+ 5-minute timestamp tolerance) and returns the event, or throws `NineError` (`invalid_signature`).
140
+
141
+ ```ts
142
+ import { verifyWebhook } from "@9in-io/sdk/server";
143
+
144
+ export default {
145
+ async fetch(request: Request, env: { NINE_WEBHOOK_SECRET: string }) {
146
+ const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
147
+ // event.id is the same on every retry and redelivery: ignore ones you have seen.
148
+ if (event.type === "user.created") await addToCrm(event.data.user);
149
+ return new Response(null, { status: 204 });
150
+ },
151
+ };
152
+ ```
153
+
154
+ It reads the body, so pass `request.clone()` if you need it again. Behind a proxy that
155
+ rewrites the URL, pass the path 9in posted to: `verifyWebhook(secret, request, { path: "/hooks/9in" })`.
156
+ Several secrets (`[newSecret, oldSecret]`) are accepted while you rotate.
157
+
158
+ ### Process each event once
159
+
160
+ 9in retries a delivery until it gets a 2xx (for about a day), and a retry can arrive while a
161
+ slow first attempt is still running. `processEventOnce` runs your handler once per event id:
162
+
163
+ ```ts
164
+ import { processEventOnce, verifyWebhook, type EventStore } from "@9in-io/sdk/server";
165
+
166
+ const event = await verifyWebhook(env.NINE_WEBHOOK_SECRET, request);
167
+ const outcome = await processEventOnce(store, event.id, () => handle(event));
168
+ // processed / duplicate → 204; in_progress → 409 (9in retries later); handler threw → id released, error rethrown (5xx)
169
+ return new Response(null, { status: outcome.status === "in_progress" ? 409 : 204 });
170
+ ```
171
+
172
+ `store` is an `EventStore` whose `add` is atomic (a Durable Object, D1/SQL with a unique key,
173
+ Redis `SET NX EX`; not Workers KV). `MemoryEventStore` covers a single process. Processed ids
174
+ are kept for 3 days by default (`ttlSeconds`).
175
+
176
+ ## Issuers must use https
177
+
178
+ `NineAuth`, `verifyAccessToken` and `ManagementClient` reject an issuer that is not `https://`
179
+ (`insecure_issuer`), so a typo cannot send tokens or credentials in clear text. Plain http is
180
+ accepted for `localhost`, `*.localhost` and loopback addresses; for other development hosts
181
+ (e.g. `http://auth:8897` in Docker) pass `allowInsecureIssuer: true`.
182
+
183
+ ## Errors
184
+
185
+ Every failure is a `NineError` with a `code` (`login_required`, `invalid_token`,
186
+ `insufficient_scope`, `invalid_grant`, `insecure_issuer`, …) and, for HTTP errors, a `status`.
@@ -0,0 +1,119 @@
1
+ export { NineError, normalizeIssuer } from "./util.js";
2
+ /**
3
+ * Browser sign-in for single-page apps (also works in React Native / Electron with a
4
+ * custom `storage` and `navigate`). Authorization code flow with PKCE, automatic
5
+ * refresh-token rotation, RP-initiated logout.
6
+ *
7
+ * const auth = new NineAuth({ issuer, clientId, redirectUri });
8
+ * await auth.login(); // redirect to the hosted login
9
+ * await auth.handleRedirect(); // on the callback page
10
+ * const user = await auth.getUser();
11
+ * const token = await auth.getAccessToken();
12
+ */
13
+ export interface StorageLike {
14
+ getItem(key: string): string | null;
15
+ setItem(key: string, value: string): void;
16
+ removeItem(key: string): void;
17
+ }
18
+ export interface NineAuthOptions {
19
+ /** Your tenant's issuer, e.g. https://acme.9in.io or https://login.acme.com */
20
+ issuer: string;
21
+ clientId: string;
22
+ redirectUri: string;
23
+ /** Default: "openid profile email offline_access". */
24
+ scope?: string;
25
+ /** API identifier to request access tokens for (aud claim). */
26
+ audience?: string;
27
+ /** Where tokens are kept. Default: localStorage (falls back to memory). */
28
+ storage?: StorageLike;
29
+ /** Where the PKCE verifier and state are kept during the redirect. Default: sessionStorage. */
30
+ transientStorage?: StorageLike;
31
+ /** Navigation hook (default: window.location.assign). */
32
+ navigate?: (url: string) => void;
33
+ /** Custom fetch (tests, React Native). */
34
+ fetch?: typeof fetch;
35
+ /** Language of the hosted pages (ui_locales). */
36
+ locale?: string;
37
+ /** Accept a plain-http issuer other than localhost (development only). */
38
+ allowInsecureIssuer?: boolean;
39
+ }
40
+ export interface LoginOptions {
41
+ /** "login" forces re-authentication, "consent" re-asks for consent, "none" fails instead of showing UI. */
42
+ prompt?: "login" | "consent" | "none" | "select_account";
43
+ /** Pre-fill the identifier field. */
44
+ loginHint?: string;
45
+ /** Open the sign-up form directly (prompt=create). */
46
+ screen?: "login" | "signup";
47
+ /** Where to send the user after handleRedirect (returned to you). */
48
+ returnTo?: string;
49
+ /** Extra authorization parameters. */
50
+ params?: Record<string, string>;
51
+ }
52
+ export interface TokenSet {
53
+ accessToken: string;
54
+ idToken: string | null;
55
+ refreshToken: string | null;
56
+ /** ms since epoch */
57
+ expiresAt: number;
58
+ scope: string;
59
+ }
60
+ export interface User {
61
+ sub: string;
62
+ email?: string;
63
+ email_verified?: boolean;
64
+ name?: string;
65
+ given_name?: string;
66
+ family_name?: string;
67
+ picture?: string;
68
+ phone_number?: string;
69
+ locale?: string;
70
+ [claim: string]: unknown;
71
+ }
72
+ export declare class NineAuth {
73
+ private readonly options;
74
+ private readonly issuer;
75
+ private readonly storage;
76
+ private readonly transient;
77
+ private readonly fetcher;
78
+ private readonly key;
79
+ private refreshing;
80
+ constructor(options: NineAuthOptions);
81
+ private navigate;
82
+ /** Builds the authorization URL and stores the PKCE verifier; `login()` navigates to it. */
83
+ buildLoginUrl(options?: LoginOptions): Promise<string>;
84
+ /** Redirects to the hosted login page. */
85
+ login(options?: LoginOptions): Promise<void>;
86
+ /** Opens the sign-up form directly. */
87
+ signup(options?: Omit<LoginOptions, "screen">): Promise<void>;
88
+ /**
89
+ * Completes sign-in on the callback page: checks state, exchanges the code with the PKCE
90
+ * verifier and stores the tokens. Removes the code from the address bar.
91
+ */
92
+ handleRedirect(url?: string): Promise<{
93
+ user: User;
94
+ returnTo?: string;
95
+ }>;
96
+ private sameIssuer;
97
+ private store;
98
+ /** Stored tokens (possibly expired). */
99
+ getTokens(): TokenSet | null;
100
+ /** True when there is a usable access token or a refresh token. */
101
+ isAuthenticated(): boolean;
102
+ /** Claims of the signed-in user from the ID token, or null. */
103
+ getUser(): Promise<User | null>;
104
+ /** Fresh claims from the userinfo endpoint. */
105
+ fetchUserInfo(): Promise<User>;
106
+ /**
107
+ * A valid access token, refreshed (with rotation) when it expires within a minute.
108
+ * Concurrent callers share one refresh. Throws `login_required` when signed out.
109
+ */
110
+ getAccessToken(): Promise<string>;
111
+ private refresh;
112
+ /** fetch() with the access token attached. */
113
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
114
+ /** Clears local tokens, revokes the refresh token and ends the 9in session. */
115
+ logout(options?: {
116
+ returnTo?: string;
117
+ local?: boolean;
118
+ }): Promise<void>;
119
+ }
package/dist/index.js ADDED
@@ -0,0 +1,238 @@
1
+ import { NineError, base64UrlEncode, decodeJwt, discover, normalizeIssuer, postForm, randomString, sha256 } from "./util.js";
2
+ export { NineError, normalizeIssuer } from "./util.js";
3
+ class MemoryStorage {
4
+ map = new Map();
5
+ getItem(key) {
6
+ return this.map.get(key) ?? null;
7
+ }
8
+ setItem(key, value) {
9
+ this.map.set(key, value);
10
+ }
11
+ removeItem(key) {
12
+ this.map.delete(key);
13
+ }
14
+ }
15
+ function defaultStorage(kind) {
16
+ try {
17
+ const store = globalThis[kind];
18
+ if (store) {
19
+ store.setItem("__9in", "1");
20
+ store.removeItem("__9in");
21
+ return store;
22
+ }
23
+ }
24
+ catch {
25
+ // storage disabled (private mode, sandboxed iframe)
26
+ }
27
+ return new MemoryStorage();
28
+ }
29
+ export class NineAuth {
30
+ options;
31
+ issuer;
32
+ storage;
33
+ transient;
34
+ fetcher;
35
+ key;
36
+ refreshing = null;
37
+ constructor(options) {
38
+ this.options = options;
39
+ if (!options.issuer || !options.clientId || !options.redirectUri)
40
+ throw new NineError("invalid_options", "issuer, clientId and redirectUri are required");
41
+ this.issuer = normalizeIssuer(options.issuer, options.allowInsecureIssuer);
42
+ this.storage = options.storage ?? defaultStorage("localStorage");
43
+ this.transient = options.transientStorage ?? defaultStorage("sessionStorage");
44
+ this.fetcher = options.fetch ?? fetch.bind(globalThis);
45
+ this.key = `9in:${this.issuer}:${options.clientId}`;
46
+ }
47
+ navigate(url) {
48
+ if (this.options.navigate)
49
+ this.options.navigate(url);
50
+ else
51
+ window.location.assign(url);
52
+ }
53
+ /** Builds the authorization URL and stores the PKCE verifier; `login()` navigates to it. */
54
+ async buildLoginUrl(options = {}) {
55
+ const config = await discover(this.issuer, this.fetcher);
56
+ const pending = { state: randomString(16), nonce: randomString(16), verifier: randomString(48), returnTo: options.returnTo };
57
+ this.transient.setItem(`${this.key}:pending`, JSON.stringify(pending));
58
+ const url = new URL(config.authorization_endpoint);
59
+ const params = {
60
+ response_type: "code",
61
+ client_id: this.options.clientId,
62
+ redirect_uri: this.options.redirectUri,
63
+ scope: this.options.scope ?? "openid profile email offline_access",
64
+ state: pending.state,
65
+ nonce: pending.nonce,
66
+ code_challenge: base64UrlEncode(await sha256(pending.verifier)),
67
+ code_challenge_method: "S256",
68
+ ...(this.options.audience ? { audience: this.options.audience } : {}),
69
+ ...(this.options.locale ? { ui_locales: this.options.locale } : {}),
70
+ ...(options.loginHint ? { login_hint: options.loginHint } : {}),
71
+ ...(options.screen === "signup" ? { prompt: "create" } : options.prompt ? { prompt: options.prompt } : {}),
72
+ ...options.params,
73
+ };
74
+ for (const [k, v] of Object.entries(params))
75
+ url.searchParams.set(k, v);
76
+ return url.toString();
77
+ }
78
+ /** Redirects to the hosted login page. */
79
+ async login(options = {}) {
80
+ this.navigate(await this.buildLoginUrl(options));
81
+ }
82
+ /** Opens the sign-up form directly. */
83
+ async signup(options = {}) {
84
+ return this.login({ ...options, screen: "signup" });
85
+ }
86
+ /**
87
+ * Completes sign-in on the callback page: checks state, exchanges the code with the PKCE
88
+ * verifier and stores the tokens. Removes the code from the address bar.
89
+ */
90
+ async handleRedirect(url = typeof window !== "undefined" ? window.location.href : "") {
91
+ const current = new URL(url);
92
+ const params = current.searchParams;
93
+ const raw = this.transient.getItem(`${this.key}:pending`);
94
+ this.transient.removeItem(`${this.key}:pending`);
95
+ if (params.get("error"))
96
+ throw new NineError(params.get("error"), params.get("error_description") ?? undefined);
97
+ const code = params.get("code");
98
+ if (!code)
99
+ throw new NineError("missing_code", "no authorization code in the URL");
100
+ if (!raw)
101
+ throw new NineError("invalid_state", "no login in progress (opened in another tab?)");
102
+ const pending = JSON.parse(raw);
103
+ if (params.get("state") !== pending.state)
104
+ throw new NineError("invalid_state", "state mismatch");
105
+ const iss = params.get("iss");
106
+ if (iss && iss.replace(/\/$/, "") !== this.issuer && !(await this.sameIssuer(iss)))
107
+ throw new NineError("invalid_issuer", `unexpected issuer ${iss}`);
108
+ const config = await discover(this.issuer, this.fetcher);
109
+ const tokens = await postForm(config.token_endpoint, { grant_type: "authorization_code", code, redirect_uri: this.options.redirectUri, client_id: this.options.clientId, code_verifier: pending.verifier }, {}, this.fetcher);
110
+ if (typeof tokens.id_token === "string") {
111
+ // The ID token came straight from the token endpoint over TLS (OIDC Core 3.1.3.7):
112
+ // its claims are checked; the signature check is for servers (verifyAccessToken).
113
+ // Checked before anything is stored, so a refused sign-in leaves no tokens behind.
114
+ let payload;
115
+ try {
116
+ payload = decodeJwt(tokens.id_token).payload;
117
+ }
118
+ catch {
119
+ throw new NineError("invalid_id_token", "malformed ID token");
120
+ }
121
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
122
+ if (!aud.includes(this.options.clientId))
123
+ throw new NineError("invalid_id_token", "audience mismatch");
124
+ if (payload.nonce !== pending.nonce)
125
+ throw new NineError("invalid_id_token", "nonce mismatch");
126
+ }
127
+ this.store(tokens);
128
+ if (typeof window !== "undefined" && window.history && url === window.location.href) {
129
+ for (const p of ["code", "state", "iss", "session_state"])
130
+ current.searchParams.delete(p);
131
+ window.history.replaceState(window.history.state, "", current.pathname + current.search + current.hash);
132
+ }
133
+ return { user: (await this.getUser()), returnTo: pending.returnTo };
134
+ }
135
+ async sameIssuer(iss) {
136
+ const config = await discover(this.issuer, this.fetcher);
137
+ return config.issuer.replace(/\/$/, "") === iss.replace(/\/$/, "");
138
+ }
139
+ store(tokens, previous) {
140
+ const set = {
141
+ accessToken: String(tokens.access_token),
142
+ idToken: typeof tokens.id_token === "string" ? tokens.id_token : (previous?.idToken ?? null),
143
+ refreshToken: typeof tokens.refresh_token === "string" ? tokens.refresh_token : (previous?.refreshToken ?? null),
144
+ expiresAt: Date.now() + Number(tokens.expires_in ?? 3600) * 1000,
145
+ scope: String(tokens.scope ?? previous?.scope ?? ""),
146
+ };
147
+ this.storage.setItem(`${this.key}:tokens`, JSON.stringify(set));
148
+ return set;
149
+ }
150
+ /** Stored tokens (possibly expired). */
151
+ getTokens() {
152
+ const raw = this.storage.getItem(`${this.key}:tokens`);
153
+ if (!raw)
154
+ return null;
155
+ try {
156
+ return JSON.parse(raw);
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ /** True when there is a usable access token or a refresh token. */
163
+ isAuthenticated() {
164
+ const t = this.getTokens();
165
+ return !!t && (t.expiresAt > Date.now() || !!t.refreshToken);
166
+ }
167
+ /** Claims of the signed-in user from the ID token, or null. */
168
+ async getUser() {
169
+ const t = this.getTokens();
170
+ if (!t?.idToken)
171
+ return null;
172
+ return decodeJwt(t.idToken).payload;
173
+ }
174
+ /** Fresh claims from the userinfo endpoint. */
175
+ async fetchUserInfo() {
176
+ const config = await discover(this.issuer, this.fetcher);
177
+ const res = await this.fetcher(config.userinfo_endpoint, { headers: { Authorization: `Bearer ${await this.getAccessToken()}` } });
178
+ if (!res.ok)
179
+ throw new NineError("userinfo_failed", `HTTP ${res.status}`, res.status);
180
+ return (await res.json());
181
+ }
182
+ /**
183
+ * A valid access token, refreshed (with rotation) when it expires within a minute.
184
+ * Concurrent callers share one refresh. Throws `login_required` when signed out.
185
+ */
186
+ async getAccessToken() {
187
+ const t = this.getTokens();
188
+ if (t && t.expiresAt - 60_000 > Date.now())
189
+ return t.accessToken;
190
+ if (!t?.refreshToken)
191
+ throw new NineError("login_required");
192
+ this.refreshing ??= this.refresh(t).finally(() => {
193
+ this.refreshing = null;
194
+ });
195
+ const next = await this.refreshing;
196
+ if (!next)
197
+ throw new NineError("login_required");
198
+ return next.accessToken;
199
+ }
200
+ async refresh(current) {
201
+ const config = await discover(this.issuer, this.fetcher);
202
+ try {
203
+ const tokens = await postForm(config.token_endpoint, { grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: this.options.clientId }, {}, this.fetcher);
204
+ return this.store(tokens, current);
205
+ }
206
+ catch (error) {
207
+ if (error instanceof NineError && error.code === "invalid_grant") {
208
+ this.storage.removeItem(`${this.key}:tokens`);
209
+ return null;
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+ /** fetch() with the access token attached. */
215
+ async fetch(input, init = {}) {
216
+ const headers = new Headers(init.headers);
217
+ headers.set("Authorization", `Bearer ${await this.getAccessToken()}`);
218
+ return this.fetcher(input, { ...init, headers });
219
+ }
220
+ /** Clears local tokens, revokes the refresh token and ends the 9in session. */
221
+ async logout(options = {}) {
222
+ const t = this.getTokens();
223
+ this.storage.removeItem(`${this.key}:tokens`);
224
+ const config = await discover(this.issuer, this.fetcher);
225
+ if (t?.refreshToken && config.revocation_endpoint) {
226
+ await postForm(config.revocation_endpoint, { token: t.refreshToken, token_type_hint: "refresh_token", client_id: this.options.clientId }, {}, this.fetcher).catch(() => undefined);
227
+ }
228
+ if (options.local)
229
+ return;
230
+ const url = new URL(config.end_session_endpoint ?? `${this.issuer}/oauth/logout`);
231
+ url.searchParams.set("client_id", this.options.clientId);
232
+ if (t?.idToken)
233
+ url.searchParams.set("id_token_hint", t.idToken);
234
+ if (options.returnTo)
235
+ url.searchParams.set("post_logout_redirect_uri", options.returnTo);
236
+ this.navigate(url.toString());
237
+ }
238
+ }