@ai-matrx/auth 0.2.1 → 0.2.2

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.
@@ -149,7 +149,7 @@ function createMatrxOAuth(config, ports) {
149
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
150
  );
151
151
  }
152
- if (!config.authUrl || !config.redirectUri) {
152
+ if (!config.authUrl || !config.redirectUri || typeof config.redirectUri === "string" && !config.redirectUri.trim()) {
153
153
  throw new MatrxOAuthError("config_missing", "The OAuth auth server URL and redirect URI are required.");
154
154
  }
155
155
  if (typeof crypto === "undefined" || !crypto.subtle) {
@@ -164,6 +164,18 @@ function createMatrxOAuth(config, ports) {
164
164
  const SESSION_KEY = `${prefix}.session`;
165
165
  const ACTIVE_KEY = `${prefix}.active-attempt`;
166
166
  const pendingKey = (state) => `${prefix}.pkce.${state}`;
167
+ const resolveRedirect = () => {
168
+ let value;
169
+ try {
170
+ value = typeof config.redirectUri === "function" ? config.redirectUri() : config.redirectUri;
171
+ } catch (error) {
172
+ throw new MatrxOAuthError("config_missing", `The sign-in redirect URI is not available here: ${String(error).slice(0, 200)}`);
173
+ }
174
+ if (typeof value !== "string" || !value.trim() || value.trim().toLowerCase() === "about:blank") {
175
+ throw new MatrxOAuthError("config_missing", "The sign-in redirect URI is not known yet, so sign-in cannot start. Try again once the browser's identity API is ready.");
176
+ }
177
+ return value;
178
+ };
167
179
  const storage = ports.storage;
168
180
  const pending = ports.pendingStorage ?? ports.storage;
169
181
  const now = ports.now ?? Date.now;
@@ -215,14 +227,16 @@ function createMatrxOAuth(config, ports) {
215
227
  async function tokenRequest(form) {
216
228
  let res;
217
229
  try {
218
- res = await doFetch(`${base}/auth/v1/oauth/token`, {
230
+ const override = form["grant_type"] === "refresh_token" ? config.refreshEndpoint : void 0;
231
+ const json = override?.body === "json";
232
+ res = await doFetch(override?.url ?? `${base}/auth/v1/oauth/token`, {
219
233
  method: "POST",
220
234
  headers: {
221
- "content-type": "application/x-www-form-urlencoded",
235
+ "content-type": json ? "application/json" : "application/x-www-form-urlencoded",
222
236
  accept: "application/json",
223
237
  ...config.publishableKey ? { apikey: config.publishableKey } : {}
224
238
  },
225
- body: new URLSearchParams(form).toString(),
239
+ body: json ? JSON.stringify({ refresh_token: form["refresh_token"] }) : new URLSearchParams(form).toString(),
226
240
  ...typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? { signal: AbortSignal.timeout(timeoutMs) } : {}
227
241
  });
228
242
  } catch (error) {
@@ -288,6 +302,7 @@ function createMatrxOAuth(config, ports) {
288
302
  });
289
303
  }
290
304
  async function signIn() {
305
+ const redirectUri = resolveRedirect();
291
306
  const verifier = generateCodeVerifier();
292
307
  const challenge = await codeChallengeFor(verifier);
293
308
  const state = generateState();
@@ -301,12 +316,12 @@ function createMatrxOAuth(config, ports) {
301
316
  const url = buildAuthorizeUrl({
302
317
  authUrl: base,
303
318
  clientId: config.clientId,
304
- redirectUri: config.redirectUri,
319
+ redirectUri,
305
320
  state,
306
321
  challenge,
307
322
  scope
308
323
  });
309
- const callbackUrl = await ports.openAuthorization(url, { state, redirectUri: config.redirectUri });
324
+ const callbackUrl = await ports.openAuthorization(url, { state, redirectUri });
310
325
  const { code, returnedState } = parseCallbackUrl(callbackUrl);
311
326
  if (!constantTimeEqual(returnedState, state)) {
312
327
  throw new MatrxOAuthError("state_mismatch", "OAuth state mismatch \u2014 possible CSRF, ignoring response");
@@ -329,7 +344,7 @@ function createMatrxOAuth(config, ports) {
329
344
  client_id: config.clientId,
330
345
  code,
331
346
  code_verifier: recovered.verifier,
332
- redirect_uri: config.redirectUri
347
+ redirect_uri: redirectUri
333
348
  });
334
349
  const session = await commit(tokens, attemptId);
335
350
  emit({ type: "signed_in", session });
@@ -341,6 +356,46 @@ function createMatrxOAuth(config, ports) {
341
356
  }
342
357
  }
343
358
  }
359
+ async function completeSignIn(captured) {
360
+ const redirectUri = resolveRedirect();
361
+ const { code, returnedState } = "callbackUrl" in captured ? parseCallbackUrl(captured.callbackUrl) : { code: captured.code, returnedState: captured.state };
362
+ if (!code || !returnedState) throw new MatrxOAuthError("callback_incomplete", "OAuth callback missing code/state");
363
+ const raw = await pending.getItem(pendingKey(returnedState));
364
+ let recovered = null;
365
+ try {
366
+ recovered = raw ? JSON.parse(raw) : null;
367
+ } catch {
368
+ recovered = null;
369
+ }
370
+ if (!recovered) {
371
+ throw new MatrxOAuthError("verifier_lost", "Could not recover code_verifier for this sign-in attempt. Please try again.");
372
+ }
373
+ try {
374
+ if (now() - recovered.createdAtMs > attemptTtlMs) {
375
+ throw new MatrxOAuthError("attempt_expired", "This sign-in took too long and expired. Start again.");
376
+ }
377
+ const tokens = await tokenRequest({
378
+ grant_type: "authorization_code",
379
+ client_id: config.clientId,
380
+ code,
381
+ code_verifier: recovered.verifier,
382
+ redirect_uri: redirectUri
383
+ });
384
+ const session = await commit(tokens, recovered.attemptId);
385
+ emit({ type: "signed_in", session });
386
+ return session;
387
+ } finally {
388
+ try {
389
+ await pending.removeItem(pendingKey(returnedState));
390
+ } catch {
391
+ }
392
+ }
393
+ }
394
+ async function adoptTokens(tokens) {
395
+ const session = await commit(tokens);
396
+ emit({ type: "signed_in", session });
397
+ return session;
398
+ }
344
399
  let inflight = null;
345
400
  async function refreshNow(rejected) {
346
401
  const stored = await loadStored();
@@ -409,13 +464,25 @@ function createMatrxOAuth(config, ports) {
409
464
  return result.token;
410
465
  }
411
466
  function singleFlight(rejected) {
412
- inflight ??= refreshNow(rejected).finally(() => {
413
- inflight = null;
467
+ if (inflight) {
468
+ const running = inflight;
469
+ if (!rejected || running.rejected.has(rejected)) return running.promise;
470
+ return running.promise.then(
471
+ (token) => token === rejected ? singleFlight(rejected) : token,
472
+ () => singleFlight(rejected)
473
+ );
474
+ }
475
+ const entry = { rejected: new Set(rejected ? [rejected] : []), promise: null };
476
+ entry.promise = refreshNow(rejected).finally(() => {
477
+ if (inflight === entry) inflight = null;
414
478
  });
415
- return inflight;
479
+ inflight = entry;
480
+ return entry.promise;
416
481
  }
417
482
  return {
418
483
  signIn,
484
+ completeSignIn,
485
+ adoptTokens,
419
486
  async getAccessToken() {
420
487
  const stored = await loadStored();
421
488
  if (!stored) return null;
@@ -1,181 +1 @@
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 };
1
+ export { A as AccessTokenClaims, c as AuthorizeUrlInput, G as GrantRefusedError, M as MatrxOAuthClient, d as MatrxOAuthConfig, f as MatrxOAuthError, g as MatrxOAuthErrorCode, h as MatrxOAuthEvent, i as MatrxOAuthPorts, j as MatrxOAuthSession, O as OpenAuthorizationContext, T as TokenSet, l as TransientAuthError, n as buildAuthorizeUrl, o as codeChallengeFor, p as constantTimeEqual, q as createMatrxOAuth, r as generateCodeVerifier, s as generateState, t as parseCallbackUrl, u as personIdFromAccessToken } from './matrx-oauth-_Ji8X5Wm.cjs';
@@ -1,181 +1 @@
1
- import { S as SessionStorage } from './storage-Dqnye4KF.js';
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 };
1
+ export { A as AccessTokenClaims, c as AuthorizeUrlInput, G as GrantRefusedError, M as MatrxOAuthClient, d as MatrxOAuthConfig, f as MatrxOAuthError, g as MatrxOAuthErrorCode, h as MatrxOAuthEvent, i as MatrxOAuthPorts, j as MatrxOAuthSession, O as OpenAuthorizationContext, T as TokenSet, l as TransientAuthError, n as buildAuthorizeUrl, o as codeChallengeFor, p as constantTimeEqual, q as createMatrxOAuth, r as generateCodeVerifier, s as generateState, t as parseCallbackUrl, u as personIdFromAccessToken } from './matrx-oauth-_Ji8X5Wm.js';
@@ -115,7 +115,7 @@ function createMatrxOAuth(config, ports) {
115
115
  "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."
116
116
  );
117
117
  }
118
- if (!config.authUrl || !config.redirectUri) {
118
+ if (!config.authUrl || !config.redirectUri || typeof config.redirectUri === "string" && !config.redirectUri.trim()) {
119
119
  throw new MatrxOAuthError("config_missing", "The OAuth auth server URL and redirect URI are required.");
120
120
  }
121
121
  if (typeof crypto === "undefined" || !crypto.subtle) {
@@ -130,6 +130,18 @@ function createMatrxOAuth(config, ports) {
130
130
  const SESSION_KEY = `${prefix}.session`;
131
131
  const ACTIVE_KEY = `${prefix}.active-attempt`;
132
132
  const pendingKey = (state) => `${prefix}.pkce.${state}`;
133
+ const resolveRedirect = () => {
134
+ let value;
135
+ try {
136
+ value = typeof config.redirectUri === "function" ? config.redirectUri() : config.redirectUri;
137
+ } catch (error) {
138
+ throw new MatrxOAuthError("config_missing", `The sign-in redirect URI is not available here: ${String(error).slice(0, 200)}`);
139
+ }
140
+ if (typeof value !== "string" || !value.trim() || value.trim().toLowerCase() === "about:blank") {
141
+ throw new MatrxOAuthError("config_missing", "The sign-in redirect URI is not known yet, so sign-in cannot start. Try again once the browser's identity API is ready.");
142
+ }
143
+ return value;
144
+ };
133
145
  const storage = ports.storage;
134
146
  const pending = ports.pendingStorage ?? ports.storage;
135
147
  const now = ports.now ?? Date.now;
@@ -181,14 +193,16 @@ function createMatrxOAuth(config, ports) {
181
193
  async function tokenRequest(form) {
182
194
  let res;
183
195
  try {
184
- res = await doFetch(`${base}/auth/v1/oauth/token`, {
196
+ const override = form["grant_type"] === "refresh_token" ? config.refreshEndpoint : void 0;
197
+ const json = override?.body === "json";
198
+ res = await doFetch(override?.url ?? `${base}/auth/v1/oauth/token`, {
185
199
  method: "POST",
186
200
  headers: {
187
- "content-type": "application/x-www-form-urlencoded",
201
+ "content-type": json ? "application/json" : "application/x-www-form-urlencoded",
188
202
  accept: "application/json",
189
203
  ...config.publishableKey ? { apikey: config.publishableKey } : {}
190
204
  },
191
- body: new URLSearchParams(form).toString(),
205
+ body: json ? JSON.stringify({ refresh_token: form["refresh_token"] }) : new URLSearchParams(form).toString(),
192
206
  ...typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? { signal: AbortSignal.timeout(timeoutMs) } : {}
193
207
  });
194
208
  } catch (error) {
@@ -254,6 +268,7 @@ function createMatrxOAuth(config, ports) {
254
268
  });
255
269
  }
256
270
  async function signIn() {
271
+ const redirectUri = resolveRedirect();
257
272
  const verifier = generateCodeVerifier();
258
273
  const challenge = await codeChallengeFor(verifier);
259
274
  const state = generateState();
@@ -267,12 +282,12 @@ function createMatrxOAuth(config, ports) {
267
282
  const url = buildAuthorizeUrl({
268
283
  authUrl: base,
269
284
  clientId: config.clientId,
270
- redirectUri: config.redirectUri,
285
+ redirectUri,
271
286
  state,
272
287
  challenge,
273
288
  scope
274
289
  });
275
- const callbackUrl = await ports.openAuthorization(url, { state, redirectUri: config.redirectUri });
290
+ const callbackUrl = await ports.openAuthorization(url, { state, redirectUri });
276
291
  const { code, returnedState } = parseCallbackUrl(callbackUrl);
277
292
  if (!constantTimeEqual(returnedState, state)) {
278
293
  throw new MatrxOAuthError("state_mismatch", "OAuth state mismatch \u2014 possible CSRF, ignoring response");
@@ -295,7 +310,7 @@ function createMatrxOAuth(config, ports) {
295
310
  client_id: config.clientId,
296
311
  code,
297
312
  code_verifier: recovered.verifier,
298
- redirect_uri: config.redirectUri
313
+ redirect_uri: redirectUri
299
314
  });
300
315
  const session = await commit(tokens, attemptId);
301
316
  emit({ type: "signed_in", session });
@@ -307,6 +322,46 @@ function createMatrxOAuth(config, ports) {
307
322
  }
308
323
  }
309
324
  }
325
+ async function completeSignIn(captured) {
326
+ const redirectUri = resolveRedirect();
327
+ const { code, returnedState } = "callbackUrl" in captured ? parseCallbackUrl(captured.callbackUrl) : { code: captured.code, returnedState: captured.state };
328
+ if (!code || !returnedState) throw new MatrxOAuthError("callback_incomplete", "OAuth callback missing code/state");
329
+ const raw = await pending.getItem(pendingKey(returnedState));
330
+ let recovered = null;
331
+ try {
332
+ recovered = raw ? JSON.parse(raw) : null;
333
+ } catch {
334
+ recovered = null;
335
+ }
336
+ if (!recovered) {
337
+ throw new MatrxOAuthError("verifier_lost", "Could not recover code_verifier for this sign-in attempt. Please try again.");
338
+ }
339
+ try {
340
+ if (now() - recovered.createdAtMs > attemptTtlMs) {
341
+ throw new MatrxOAuthError("attempt_expired", "This sign-in took too long and expired. Start again.");
342
+ }
343
+ const tokens = await tokenRequest({
344
+ grant_type: "authorization_code",
345
+ client_id: config.clientId,
346
+ code,
347
+ code_verifier: recovered.verifier,
348
+ redirect_uri: redirectUri
349
+ });
350
+ const session = await commit(tokens, recovered.attemptId);
351
+ emit({ type: "signed_in", session });
352
+ return session;
353
+ } finally {
354
+ try {
355
+ await pending.removeItem(pendingKey(returnedState));
356
+ } catch {
357
+ }
358
+ }
359
+ }
360
+ async function adoptTokens(tokens) {
361
+ const session = await commit(tokens);
362
+ emit({ type: "signed_in", session });
363
+ return session;
364
+ }
310
365
  let inflight = null;
311
366
  async function refreshNow(rejected) {
312
367
  const stored = await loadStored();
@@ -375,13 +430,25 @@ function createMatrxOAuth(config, ports) {
375
430
  return result.token;
376
431
  }
377
432
  function singleFlight(rejected) {
378
- inflight ??= refreshNow(rejected).finally(() => {
379
- inflight = null;
433
+ if (inflight) {
434
+ const running = inflight;
435
+ if (!rejected || running.rejected.has(rejected)) return running.promise;
436
+ return running.promise.then(
437
+ (token) => token === rejected ? singleFlight(rejected) : token,
438
+ () => singleFlight(rejected)
439
+ );
440
+ }
441
+ const entry = { rejected: new Set(rejected ? [rejected] : []), promise: null };
442
+ entry.promise = refreshNow(rejected).finally(() => {
443
+ if (inflight === entry) inflight = null;
380
444
  });
381
- return inflight;
445
+ inflight = entry;
446
+ return entry.promise;
382
447
  }
383
448
  return {
384
449
  signIn,
450
+ completeSignIn,
451
+ adoptTokens,
385
452
  async getAccessToken() {
386
453
  const stored = await loadStored();
387
454
  if (!stored) return null;