@ai-matrx/auth 0.1.3 → 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.
@@ -0,0 +1,458 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/matrx-oauth.ts
21
+ var matrx_oauth_exports = {};
22
+ __export(matrx_oauth_exports, {
23
+ GrantRefusedError: () => GrantRefusedError,
24
+ MatrxOAuthError: () => MatrxOAuthError,
25
+ TransientAuthError: () => TransientAuthError,
26
+ buildAuthorizeUrl: () => buildAuthorizeUrl,
27
+ codeChallengeFor: () => codeChallengeFor,
28
+ constantTimeEqual: () => constantTimeEqual,
29
+ createMatrxOAuth: () => createMatrxOAuth,
30
+ generateCodeVerifier: () => generateCodeVerifier,
31
+ generateState: () => generateState,
32
+ parseCallbackUrl: () => parseCallbackUrl,
33
+ personIdFromAccessToken: () => personIdFromAccessToken
34
+ });
35
+ module.exports = __toCommonJS(matrx_oauth_exports);
36
+ var MatrxOAuthError = class extends Error {
37
+ code;
38
+ constructor(code, message) {
39
+ super(message);
40
+ this.name = "MatrxOAuthError";
41
+ this.code = code;
42
+ }
43
+ };
44
+ var GrantRefusedError = class extends MatrxOAuthError {
45
+ serverCode;
46
+ constructor(serverCode, detail) {
47
+ super("grant_refused", `sign-in refused (${serverCode}): ${detail}`);
48
+ this.name = "GrantRefusedError";
49
+ this.serverCode = serverCode;
50
+ }
51
+ };
52
+ var TransientAuthError = class extends MatrxOAuthError {
53
+ constructor(detail) {
54
+ super("transient", detail);
55
+ this.name = "TransientAuthError";
56
+ }
57
+ };
58
+ var TERMINAL_CODES = /* @__PURE__ */ new Set([
59
+ "invalid_grant",
60
+ "invalid_request",
61
+ "unauthorized_client",
62
+ "refresh_token_not_found",
63
+ "refresh_token_already_used",
64
+ "session_expired",
65
+ "session_not_found"
66
+ ]);
67
+ function base64UrlEncode(bytes) {
68
+ let s = "";
69
+ for (let i = 0; i < bytes.byteLength; i++) s += String.fromCharCode(bytes[i]);
70
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
71
+ }
72
+ function base64UrlDecodeToString(input) {
73
+ const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
74
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
75
+ const bin = atob(padded);
76
+ const bytes = new Uint8Array(bin.length);
77
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
78
+ return new TextDecoder().decode(bytes);
79
+ }
80
+ function generateCodeVerifier() {
81
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
82
+ }
83
+ async function codeChallengeFor(verifier) {
84
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
85
+ return base64UrlEncode(new Uint8Array(hash));
86
+ }
87
+ function generateState() {
88
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
89
+ }
90
+ function constantTimeEqual(a, b) {
91
+ if (a.length !== b.length) return false;
92
+ let diff = 0;
93
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
94
+ return diff === 0;
95
+ }
96
+ function buildAuthorizeUrl(input) {
97
+ const url = new URL(`${input.authUrl.replace(/\/+$/, "")}/auth/v1/oauth/authorize`);
98
+ url.searchParams.set("response_type", "code");
99
+ url.searchParams.set("client_id", input.clientId);
100
+ url.searchParams.set("redirect_uri", input.redirectUri);
101
+ url.searchParams.set("state", input.state);
102
+ url.searchParams.set("code_challenge", input.challenge);
103
+ url.searchParams.set("code_challenge_method", "S256");
104
+ url.searchParams.set("scope", input.scope);
105
+ return url.toString();
106
+ }
107
+ function parseCallbackUrl(callbackUrl) {
108
+ const u = new URL(callbackUrl);
109
+ const error = u.searchParams.get("error") || u.searchParams.get("error_description");
110
+ if (error) throw new MatrxOAuthError("callback_error", `OAuth error: ${error}`);
111
+ const code = u.searchParams.get("code");
112
+ const returnedState = u.searchParams.get("state");
113
+ if (!code || !returnedState) throw new MatrxOAuthError("callback_incomplete", "OAuth callback missing code/state");
114
+ return { code, returnedState };
115
+ }
116
+ function personIdFromAccessToken(token) {
117
+ const part = token.split(".")[1];
118
+ if (!part) throw new MatrxOAuthError("invalid_token", "the access token is not a JWT");
119
+ let payload;
120
+ try {
121
+ payload = JSON.parse(base64UrlDecodeToString(part));
122
+ } catch {
123
+ throw new MatrxOAuthError("invalid_token", "the access token payload is unreadable");
124
+ }
125
+ if (typeof payload.sub !== "string" || payload.sub.length === 0) {
126
+ throw new MatrxOAuthError("invalid_token", "the access token has no subject");
127
+ }
128
+ return {
129
+ personId: payload.sub,
130
+ email: typeof payload.email === "string" && payload.email.length > 0 ? payload.email : null,
131
+ expiresAtMs: typeof payload.exp === "number" ? payload.exp * 1e3 : null
132
+ };
133
+ }
134
+ function extractErrorMessage(raw) {
135
+ try {
136
+ const j = JSON.parse(raw);
137
+ for (const key of ["error_description", "error_message", "msg", "error", "code"]) {
138
+ const v = j[key];
139
+ if (typeof v === "string" && v.length > 0) return v;
140
+ }
141
+ } catch {
142
+ }
143
+ return null;
144
+ }
145
+ function createMatrxOAuth(config, ports) {
146
+ if (!config.clientId) {
147
+ throw new MatrxOAuthError(
148
+ "config_missing",
149
+ "The OAuth client id is not set. Register this app as a public PKCE client in the Matrx auth server, then pass its client id."
150
+ );
151
+ }
152
+ if (!config.authUrl || !config.redirectUri) {
153
+ throw new MatrxOAuthError("config_missing", "The OAuth auth server URL and redirect URI are required.");
154
+ }
155
+ if (typeof crypto === "undefined" || !crypto.subtle) {
156
+ throw new MatrxOAuthError("unsupported_environment", "WebCrypto is not available here, so sign-in cannot run.");
157
+ }
158
+ const base = config.authUrl.replace(/\/+$/, "");
159
+ const scope = config.scope ?? "email profile";
160
+ const refreshLeadMs = config.refreshLeadMs ?? 6e4;
161
+ const attemptTtlMs = config.attemptTtlMs ?? 6e5;
162
+ const timeoutMs = config.requestTimeoutMs ?? 15e3;
163
+ const prefix = config.storageKeyPrefix ?? "matrx-oauth";
164
+ const SESSION_KEY = `${prefix}.session`;
165
+ const ACTIVE_KEY = `${prefix}.active-attempt`;
166
+ const pendingKey = (state) => `${prefix}.pkce.${state}`;
167
+ const storage = ports.storage;
168
+ const pending = ports.pendingStorage ?? ports.storage;
169
+ const now = ports.now ?? Date.now;
170
+ const doFetch = (...args) => (ports.fetch ?? fetch)(...args);
171
+ const listeners = /* @__PURE__ */ new Set();
172
+ const emit = (e) => {
173
+ for (const l of [...listeners]) {
174
+ try {
175
+ l(e);
176
+ } catch {
177
+ }
178
+ }
179
+ };
180
+ let chain = Promise.resolve();
181
+ const lock = ports.lock ?? ((fn) => {
182
+ const run = chain.then(fn, fn);
183
+ chain = run.catch(() => void 0);
184
+ return run;
185
+ });
186
+ const seal = (s) => ports.seal ? ports.seal.encrypt(s) : Promise.resolve(s);
187
+ const unseal = (s) => ports.seal ? ports.seal.decrypt(s) : Promise.resolve(s);
188
+ async function loadStored() {
189
+ const raw = await storage.getItem(SESSION_KEY);
190
+ if (!raw) return null;
191
+ try {
192
+ const parsed = JSON.parse(raw);
193
+ if (parsed?.v !== 1 || typeof parsed.accessToken !== "string" || typeof parsed.refreshToken !== "string") {
194
+ throw new Error("shape");
195
+ }
196
+ return parsed;
197
+ } catch {
198
+ await storage.removeItem(SESSION_KEY);
199
+ emit({ type: "signed_out", reason: "unreadable" });
200
+ return null;
201
+ }
202
+ }
203
+ const toSession = (s) => ({
204
+ personId: s.personId,
205
+ email: s.email,
206
+ accessToken: s.accessToken,
207
+ expiresAtMs: s.expiresAtMs
208
+ });
209
+ async function clearLocal() {
210
+ const stored = await loadStored();
211
+ await storage.removeItem(SESSION_KEY);
212
+ await pending.removeItem(ACTIVE_KEY);
213
+ return stored?.accessToken ?? null;
214
+ }
215
+ async function tokenRequest(form) {
216
+ let res;
217
+ try {
218
+ res = await doFetch(`${base}/auth/v1/oauth/token`, {
219
+ method: "POST",
220
+ headers: {
221
+ "content-type": "application/x-www-form-urlencoded",
222
+ accept: "application/json",
223
+ ...config.publishableKey ? { apikey: config.publishableKey } : {}
224
+ },
225
+ body: new URLSearchParams(form).toString(),
226
+ ...typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? { signal: AbortSignal.timeout(timeoutMs) } : {}
227
+ });
228
+ } catch (error) {
229
+ throw new TransientAuthError(`the sign-in service is unreachable: ${String(error).slice(0, 200)}`);
230
+ }
231
+ const type = res.headers.get("content-type") ?? "";
232
+ const text = await res.text().catch(() => "");
233
+ const isExchange = form["grant_type"] === "authorization_code";
234
+ if (!type.includes("json")) {
235
+ throw new TransientAuthError(`the sign-in service answered ${res.status} with ${type || "no content type"}`);
236
+ }
237
+ let body;
238
+ try {
239
+ body = JSON.parse(text);
240
+ } catch {
241
+ throw new TransientAuthError(`the sign-in service answered ${res.status} with unreadable JSON`);
242
+ }
243
+ if (res.ok) {
244
+ const access = body["access_token"];
245
+ if (typeof access !== "string" || access.length === 0) {
246
+ throw new TransientAuthError("the token response carried no access token");
247
+ }
248
+ const refresh = body["refresh_token"];
249
+ const expiresIn = typeof body["expires_in"] === "number" && body["expires_in"] > 0 ? body["expires_in"] : 3600;
250
+ return {
251
+ accessToken: access,
252
+ refreshToken: typeof refresh === "string" && refresh.length > 0 ? refresh : null,
253
+ expiresAtMs: now() + expiresIn * 1e3
254
+ };
255
+ }
256
+ const code = String(body["error"] ?? body["error_code"] ?? res.status);
257
+ const detail = extractErrorMessage(text) ?? text.slice(0, 200);
258
+ if (isExchange) {
259
+ throw new MatrxOAuthError("token_exchange_failed", `Token exchange failed (${res.status}): ${detail}`);
260
+ }
261
+ if ((res.status === 400 || res.status === 401 || res.status === 403) && TERMINAL_CODES.has(code)) {
262
+ throw new GrantRefusedError(code, String(body["error_description"] ?? body["msg"] ?? body["message"] ?? ""));
263
+ }
264
+ throw new TransientAuthError(`the sign-in service answered ${res.status} ${code}: ${detail.slice(0, 200)}`);
265
+ }
266
+ async function commit(tokens, attemptId) {
267
+ if (!tokens.refreshToken) {
268
+ throw new MatrxOAuthError("token_exchange_failed", "Token exchange failed: the sign-in returned no refresh token");
269
+ }
270
+ const claims = personIdFromAccessToken(tokens.accessToken);
271
+ if (ports.beforeCommit) await ports.beforeCommit(tokens.accessToken);
272
+ const sealed = await seal(tokens.refreshToken);
273
+ return lock(async () => {
274
+ if (attemptId !== void 0 && await pending.getItem(ACTIVE_KEY) !== attemptId) {
275
+ throw new MatrxOAuthError("superseded", "This sign-in attempt was superseded by a newer authentication change");
276
+ }
277
+ const stored = {
278
+ v: 1,
279
+ clientId: config.clientId,
280
+ personId: claims.personId,
281
+ email: claims.email,
282
+ accessToken: tokens.accessToken,
283
+ refreshToken: sealed,
284
+ expiresAtMs: tokens.expiresAtMs
285
+ };
286
+ await storage.setItem(SESSION_KEY, JSON.stringify(stored));
287
+ return toSession(stored);
288
+ });
289
+ }
290
+ async function signIn() {
291
+ const verifier = generateCodeVerifier();
292
+ const challenge = await codeChallengeFor(verifier);
293
+ const state = generateState();
294
+ const attemptId = `oauth:${state}`;
295
+ const attempt = { verifier, createdAtMs: now(), attemptId };
296
+ await lock(async () => {
297
+ await pending.setItem(pendingKey(state), JSON.stringify(attempt));
298
+ await pending.setItem(ACTIVE_KEY, attemptId);
299
+ });
300
+ try {
301
+ const url = buildAuthorizeUrl({
302
+ authUrl: base,
303
+ clientId: config.clientId,
304
+ redirectUri: config.redirectUri,
305
+ state,
306
+ challenge,
307
+ scope
308
+ });
309
+ const callbackUrl = await ports.openAuthorization(url, { state, redirectUri: config.redirectUri });
310
+ const { code, returnedState } = parseCallbackUrl(callbackUrl);
311
+ if (!constantTimeEqual(returnedState, state)) {
312
+ throw new MatrxOAuthError("state_mismatch", "OAuth state mismatch \u2014 possible CSRF, ignoring response");
313
+ }
314
+ const raw = await pending.getItem(pendingKey(state));
315
+ let recovered = null;
316
+ try {
317
+ recovered = raw ? JSON.parse(raw) : null;
318
+ } catch {
319
+ recovered = null;
320
+ }
321
+ if (!recovered || recovered.verifier !== verifier) {
322
+ throw new MatrxOAuthError("verifier_lost", "Could not recover code_verifier for this sign-in attempt");
323
+ }
324
+ if (now() - recovered.createdAtMs > attemptTtlMs) {
325
+ throw new MatrxOAuthError("attempt_expired", "This sign-in took too long and expired. Start again.");
326
+ }
327
+ const tokens = await tokenRequest({
328
+ grant_type: "authorization_code",
329
+ client_id: config.clientId,
330
+ code,
331
+ code_verifier: recovered.verifier,
332
+ redirect_uri: config.redirectUri
333
+ });
334
+ const session = await commit(tokens, attemptId);
335
+ emit({ type: "signed_in", session });
336
+ return session;
337
+ } finally {
338
+ try {
339
+ await pending.removeItem(pendingKey(state));
340
+ } catch {
341
+ }
342
+ }
343
+ }
344
+ let inflight = null;
345
+ async function refreshNow(rejected) {
346
+ const stored = await loadStored();
347
+ if (!stored) return null;
348
+ if (stored.clientId !== config.clientId) {
349
+ await lock(clearLocal);
350
+ emit({ type: "signed_out", reason: "client_changed" });
351
+ return null;
352
+ }
353
+ if (stored.expiresAtMs - now() > refreshLeadMs && (!rejected || stored.accessToken !== rejected)) {
354
+ return stored.accessToken;
355
+ }
356
+ let refreshToken;
357
+ try {
358
+ refreshToken = await unseal(stored.refreshToken);
359
+ } catch {
360
+ await lock(async () => {
361
+ const again = await loadStored();
362
+ if (again && again.refreshToken === stored.refreshToken) await clearLocal();
363
+ });
364
+ emit({ type: "signed_out", reason: "unreadable" });
365
+ return null;
366
+ }
367
+ let tokens;
368
+ try {
369
+ tokens = await tokenRequest({ grant_type: "refresh_token", client_id: config.clientId, refresh_token: refreshToken });
370
+ } catch (error) {
371
+ if (error instanceof GrantRefusedError) {
372
+ const outcome = await lock(async () => {
373
+ const current = await loadStored();
374
+ if (!current) return { kind: "gone" };
375
+ if (current.refreshToken !== stored.refreshToken) return { kind: "rotated", token: current.accessToken };
376
+ await clearLocal();
377
+ return { kind: "cleared" };
378
+ });
379
+ if (outcome.kind === "rotated") return outcome.token;
380
+ if (outcome.kind === "cleared") emit({ type: "signed_out", reason: "session_ended" });
381
+ return null;
382
+ }
383
+ if (stored.expiresAtMs > now() && stored.accessToken !== rejected) return stored.accessToken;
384
+ throw error;
385
+ }
386
+ const claims = personIdFromAccessToken(tokens.accessToken);
387
+ if (claims.personId !== stored.personId) {
388
+ await lock(clearLocal);
389
+ emit({ type: "signed_out", reason: "person_changed" });
390
+ return null;
391
+ }
392
+ const sealedNew = tokens.refreshToken ? await seal(tokens.refreshToken) : stored.refreshToken;
393
+ const result = await lock(async () => {
394
+ const current = await loadStored();
395
+ if (!current) return null;
396
+ if (current.refreshToken !== stored.refreshToken) return { token: current.accessToken, session: null };
397
+ const next = {
398
+ ...current,
399
+ email: claims.email ?? current.email,
400
+ accessToken: tokens.accessToken,
401
+ refreshToken: sealedNew,
402
+ expiresAtMs: tokens.expiresAtMs
403
+ };
404
+ await storage.setItem(SESSION_KEY, JSON.stringify(next));
405
+ return { token: next.accessToken, session: toSession(next) };
406
+ });
407
+ if (!result) return null;
408
+ if (result.session) emit({ type: "refreshed", session: result.session });
409
+ return result.token;
410
+ }
411
+ function singleFlight(rejected) {
412
+ inflight ??= refreshNow(rejected).finally(() => {
413
+ inflight = null;
414
+ });
415
+ return inflight;
416
+ }
417
+ return {
418
+ signIn,
419
+ async getAccessToken() {
420
+ const stored = await loadStored();
421
+ if (!stored) return null;
422
+ if (stored.clientId === config.clientId && stored.expiresAtMs - now() > refreshLeadMs) return stored.accessToken;
423
+ return singleFlight();
424
+ },
425
+ refresh: (rejectedAccessToken) => singleFlight(rejectedAccessToken),
426
+ async getSession() {
427
+ const stored = await loadStored();
428
+ return stored ? toSession(stored) : null;
429
+ },
430
+ async getPersonId() {
431
+ const stored = await loadStored();
432
+ return stored ? personIdFromAccessToken(stored.accessToken).personId : null;
433
+ },
434
+ async signOut() {
435
+ const access = await lock(clearLocal);
436
+ emit({ type: "signed_out", reason: "user" });
437
+ if (!access) return { revoked: null };
438
+ try {
439
+ const res = await doFetch(`${base}/auth/v1/logout?scope=local`, {
440
+ method: "POST",
441
+ headers: {
442
+ authorization: `Bearer ${access}`,
443
+ ...config.publishableKey ? { apikey: config.publishableKey } : {}
444
+ }
445
+ });
446
+ return { revoked: res.ok || res.status === 401 || res.status === 403 || res.status === 404 };
447
+ } catch {
448
+ return { revoked: false };
449
+ }
450
+ },
451
+ onChange(listener) {
452
+ listeners.add(listener);
453
+ return () => {
454
+ listeners.delete(listener);
455
+ };
456
+ }
457
+ };
458
+ }
@@ -0,0 +1,181 @@
1
+ import { S as SessionStorage } from './storage-Dqnye4KF.cjs';
2
+
3
+ /**
4
+ * "Sign in with AI Matrx" — OAuth 2.1 authorization-code + PKCE (S256) against the AI Matrx auth
5
+ * server (Supabase's OAuth 2.1 server on https://db.matrxserver.com), written ONCE, host-neutral.
6
+ *
7
+ * Two apps used to carry hand-made copies; both now import this:
8
+ * - Chrome extension (matrx-extend/src/lib/auth/flow.ts): storage = chrome.storage, redirect
9
+ * captured by chrome.identity.launchWebAuthFlow, cross-context `navigator.locks`, AES-GCM
10
+ * sealed refresh token.
11
+ * - Desktop (matrx-desktop/app/main/auth): storage = the OS-sealed vault, authorize URL opened
12
+ * in the system browser, redirect captured by a loopback server on localhost.
13
+ *
14
+ * NOTHING host-specific is imported here (no chrome.*, electron, node:*, window). The host injects
15
+ * ports: `storage` (the package's SessionStorage shape), `openAuthorization`, and optionally
16
+ * `fetch`, `now`, `lock`, `seal`, `pendingStorage`, `beforeCommit`. Runs on WebCrypto
17
+ * (browser, extension service worker, Node >= 20).
18
+ *
19
+ * Config each app passes in (values they use today; this module hard-codes none of the ids):
20
+ * authUrl both: https://db.matrxserver.com (endpoints: /auth/v1/oauth/{authorize,token},
21
+ * /auth/v1/logout?scope=local)
22
+ * clientId extension: its own public PKCE client (env WXT_EXTENSION_OAUTH_CLIENT_ID);
23
+ * desktop: "Matrx 2" public client (see matrx-desktop/app/main/auth/config.ts)
24
+ * redirectUri extension: chrome.identity.getRedirectURL() = https://<id>.chromiumapp.org/
25
+ * (Safari: https://www.aimatrx.com/auth/extension-callback);
26
+ * desktop: http://localhost:22140/auth/callback (dev: 22240)
27
+ * scope both: "email profile". NEVER add "openid": the server then tries to mint an
28
+ * ID token and answers 500 "Error generating ID token".
29
+ * publishableKey desktop sends it as `apikey` on token + logout calls; extension on logout.
30
+ * Optional here; sent whenever given.
31
+ * refreshLeadMs extension 60_000, desktop 120_000 (default 60_000)
32
+ *
33
+ * Behaviour kept from the extension: verifier PERSISTED locally and only a random nonce travels as
34
+ * `state` (never the verifier in the URL); one verifier per attempt keyed by state; a newer
35
+ * attempt/sign-out supersedes an older one; state mismatch refused; the exact user-facing
36
+ * sentences; the cross-context refresh race guard (if the stored refresh token changed while our
37
+ * refresh was refused, adopt the winner instead of signing out); optional pre-commit validation.
38
+ * Kept from the desktop: constant-time state compare, 10 min attempt expiry, terminal-vs-transient
39
+ * error split (a refused grant ends the session; a network blip / 5xx / captive portal keeps it),
40
+ * HTML-on-200 is never a token, client-changed sessions dropped, refresh timeout, person id from JWT.
41
+ */
42
+
43
+ type MatrxOAuthErrorCode = "config_missing" | "state_mismatch" | "callback_error" | "callback_incomplete" | "attempt_expired" | "verifier_lost" | "superseded" | "token_exchange_failed" | "invalid_token" | "grant_refused" | "transient" | "unsupported_environment";
44
+ declare class MatrxOAuthError extends Error {
45
+ readonly code: MatrxOAuthErrorCode;
46
+ constructor(code: MatrxOAuthErrorCode, message: string);
47
+ }
48
+ /** The server refused the grant (revoked, reused, expired): the session is over. Never retried. */
49
+ declare class GrantRefusedError extends MatrxOAuthError {
50
+ readonly serverCode: string;
51
+ constructor(serverCode: string, detail: string);
52
+ }
53
+ /** Network down, captive portal, 5xx, 429: try again later; the session is still good. */
54
+ declare class TransientAuthError extends MatrxOAuthError {
55
+ constructor(detail: string);
56
+ }
57
+ /** RFC 7636 §4.1: 43 chars of base64url from 32 random bytes. */
58
+ declare function generateCodeVerifier(): string;
59
+ /** RFC 7636 §4.2 S256: BASE64URL(SHA256(ASCII(verifier))). */
60
+ declare function codeChallengeFor(verifier: string): Promise<string>;
61
+ /** The `state` value: 256 random bits, ties the callback to this attempt. */
62
+ declare function generateState(): string;
63
+ /** Constant-time string comparison. */
64
+ declare function constantTimeEqual(a: string, b: string): boolean;
65
+ interface AuthorizeUrlInput {
66
+ authUrl: string;
67
+ clientId: string;
68
+ redirectUri: string;
69
+ state: string;
70
+ challenge: string;
71
+ scope: string;
72
+ }
73
+ declare function buildAuthorizeUrl(input: AuthorizeUrlInput): string;
74
+ /** Read `code` and `state` from the redirect the browser landed on; throws on error / missing parts. */
75
+ declare function parseCallbackUrl(callbackUrl: string): {
76
+ code: string;
77
+ returnedState: string;
78
+ };
79
+ interface AccessTokenClaims {
80
+ /** The person id (`sub`). */
81
+ personId: string;
82
+ email: string | null;
83
+ /** Expiry in ms since epoch, or null when the token carries no `exp`. */
84
+ expiresAtMs: number | null;
85
+ }
86
+ /**
87
+ * Read `sub`, `email`, `exp` from an access token. The signature is NOT verified here: the token
88
+ * came straight from the issuer over TLS. Verify-on-receipt belongs to servers.
89
+ */
90
+ declare function personIdFromAccessToken(token: string): AccessTokenClaims;
91
+ interface MatrxOAuthConfig {
92
+ /** Auth server origin, e.g. https://db.matrxserver.com */
93
+ authUrl: string;
94
+ /** This app's public PKCE client id. */
95
+ clientId: string;
96
+ redirectUri: string;
97
+ /** Default "email profile". Never include "openid". */
98
+ scope?: string;
99
+ /** Sent as `apikey` on token + logout calls when given. */
100
+ publishableKey?: string;
101
+ /** Refresh this long before expiry. Default 60_000. */
102
+ refreshLeadMs?: number;
103
+ /** A started sign-in expires after this. Default 600_000. */
104
+ attemptTtlMs?: number;
105
+ /** Token endpoint timeout. Default 15_000. */
106
+ requestTimeoutMs?: number;
107
+ /** Storage key namespace. Default "matrx-oauth". */
108
+ storageKeyPrefix?: string;
109
+ }
110
+ interface OpenAuthorizationContext {
111
+ state: string;
112
+ redirectUri: string;
113
+ }
114
+ interface MatrxOAuthPorts {
115
+ /** Where the session lives between runs (the package's SessionStorage shape). */
116
+ storage: SessionStorage;
117
+ /**
118
+ * Open the authorize URL and resolve with the full redirect URL the browser landed on.
119
+ * Reject if the person cancelled. Extension: chrome.identity.launchWebAuthFlow.
120
+ * Desktop: open the system browser and wait on the loopback server.
121
+ */
122
+ openAuthorization(authorizeUrl: string, context: OpenAuthorizationContext): Promise<string>;
123
+ fetch?: typeof fetch;
124
+ now?: () => number;
125
+ /** Short-lived attempt state (verifier). Default: `storage`. Extension: chrome.storage.session. */
126
+ pendingStorage?: SessionStorage;
127
+ /** Serialises storage mutations across contexts (extension: navigator.locks). Default: in-process. */
128
+ lock?: <T>(fn: () => Promise<T>) => Promise<T>;
129
+ /** Seal/unseal the refresh token at rest (extension: AES-GCM). Default: stored as-is. */
130
+ seal?: {
131
+ encrypt(plain: string): Promise<string>;
132
+ decrypt(sealed: string): Promise<string>;
133
+ };
134
+ /**
135
+ * Runs on freshly exchanged tokens BEFORE they displace a working session; throw to refuse
136
+ * (extension: GET /auth/v1/user). Never call per request.
137
+ */
138
+ beforeCommit?: (accessToken: string) => Promise<void>;
139
+ }
140
+ interface MatrxOAuthSession {
141
+ personId: string;
142
+ email: string | null;
143
+ accessToken: string;
144
+ expiresAtMs: number;
145
+ }
146
+ type MatrxOAuthEvent = {
147
+ type: "signed_in";
148
+ session: MatrxOAuthSession;
149
+ } | {
150
+ type: "refreshed";
151
+ session: MatrxOAuthSession;
152
+ } | {
153
+ type: "signed_out";
154
+ reason: "user" | "session_ended" | "client_changed" | "person_changed" | "unreadable";
155
+ };
156
+ interface TokenSet {
157
+ accessToken: string;
158
+ /** Null only if the server chose not to rotate it. */
159
+ refreshToken: string | null;
160
+ expiresAtMs: number;
161
+ }
162
+ interface MatrxOAuthClient {
163
+ /** Run the whole interactive sign-in. Resolves once tokens are stored. */
164
+ signIn(): Promise<MatrxOAuthSession>;
165
+ /** A usable access token, refreshing on expiry (single-flight). Null = signed out. Throws on transient failure with no valid token left. */
166
+ getAccessToken(): Promise<string | null>;
167
+ /** Force a refresh (alarms, 401 retry). Pass the token the server rejected to refuse adopting it. */
168
+ refresh(rejectedAccessToken?: string): Promise<string | null>;
169
+ /** The stored session without refreshing or touching the network. */
170
+ getSession(): Promise<MatrxOAuthSession | null>;
171
+ /** The person id from the stored access token, no network. Null = signed out. */
172
+ getPersonId(): Promise<string | null>;
173
+ /** Clear local state, then best-effort revoke on the server. Returns whether the server agreed (null = nothing to revoke). */
174
+ signOut(): Promise<{
175
+ revoked: boolean | null;
176
+ }>;
177
+ onChange(listener: (event: MatrxOAuthEvent) => void): () => void;
178
+ }
179
+ declare function createMatrxOAuth(config: MatrxOAuthConfig, ports: MatrxOAuthPorts): MatrxOAuthClient;
180
+
181
+ export { type AccessTokenClaims, type AuthorizeUrlInput, GrantRefusedError, type MatrxOAuthClient, type MatrxOAuthConfig, MatrxOAuthError, type MatrxOAuthErrorCode, type MatrxOAuthEvent, type MatrxOAuthPorts, type MatrxOAuthSession, type OpenAuthorizationContext, type TokenSet, TransientAuthError, buildAuthorizeUrl, codeChallengeFor, constantTimeEqual, createMatrxOAuth, generateCodeVerifier, generateState, parseCallbackUrl, personIdFromAccessToken };