@ai-matrx/auth 0.2.0 → 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.
- package/CHANGELOG.md +47 -0
- package/dist/active-organization-Dy4UtiyR.d.cts +594 -0
- package/dist/active-organization-Dy4UtiyR.d.ts +594 -0
- package/dist/guest.cjs +213 -0
- package/dist/guest.d.cts +189 -0
- package/dist/guest.d.ts +189 -0
- package/dist/guest.js +192 -0
- package/dist/index.cjs +514 -112
- package/dist/index.d.cts +161 -5
- package/dist/index.d.ts +161 -5
- package/dist/index.js +515 -113
- package/dist/matrx-oauth-_Ji8X5Wm.d.cts +264 -0
- package/dist/matrx-oauth-_Ji8X5Wm.d.ts +264 -0
- package/dist/matrx-oauth.cjs +77 -10
- package/dist/matrx-oauth.d.cts +1 -181
- package/dist/matrx-oauth.d.ts +1 -181
- package/dist/matrx-oauth.js +77 -10
- package/dist/organizations.cjs +269 -13
- package/dist/organizations.d.cts +3 -225
- package/dist/organizations.d.ts +3 -225
- package/dist/organizations.js +269 -13
- package/dist/react.d.cts +3 -1
- package/dist/react.d.ts +3 -1
- package/package.json +12 -2
- package/dist/session-DdQiStp6.d.cts +0 -112
- package/dist/session-DdQiStp6.d.ts +0 -112
- package/dist/storage-Dqnye4KF.d.cts +0 -51
- package/dist/storage-Dqnye4KF.d.ts +0 -51
package/dist/matrx-oauth.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
319
|
+
redirectUri,
|
|
305
320
|
state,
|
|
306
321
|
challenge,
|
|
307
322
|
scope
|
|
308
323
|
});
|
|
309
|
-
const callbackUrl = await ports.openAuthorization(url, { state, 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:
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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;
|
package/dist/matrx-oauth.d.cts
CHANGED
|
@@ -1,181 +1 @@
|
|
|
1
|
-
|
|
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';
|
package/dist/matrx-oauth.d.ts
CHANGED
|
@@ -1,181 +1 @@
|
|
|
1
|
-
|
|
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';
|
package/dist/matrx-oauth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
285
|
+
redirectUri,
|
|
271
286
|
state,
|
|
272
287
|
challenge,
|
|
273
288
|
scope
|
|
274
289
|
});
|
|
275
|
-
const callbackUrl = await ports.openAuthorization(url, { state, 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:
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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;
|