@ai-matrx/auth 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +11 -0
- package/dist/claims-DXdbe3cW.d.cts +138 -0
- package/dist/claims-DXdbe3cW.d.ts +138 -0
- package/dist/index.cjs +135 -1
- package/dist/index.d.cts +4 -51
- package/dist/index.d.ts +4 -51
- package/dist/index.js +138 -1
- package/dist/matrx-oauth.cjs +458 -0
- package/dist/matrx-oauth.d.cts +181 -0
- package/dist/matrx-oauth.d.ts +181 -0
- package/dist/matrx-oauth.js +437 -0
- package/dist/next.cjs +133 -1
- package/dist/next.d.cts +77 -1
- package/dist/next.d.ts +77 -1
- package/dist/next.js +136 -1
- package/dist/node.cjs +139 -2
- package/dist/node.d.cts +117 -1
- package/dist/node.d.ts +117 -1
- package/dist/node.js +136 -1
- package/dist/organizations.cjs +94 -0
- package/dist/organizations.d.cts +107 -1
- package/dist/organizations.d.ts +107 -1
- package/dist/organizations.js +94 -0
- package/dist/storage-Dqnye4KF.d.cts +51 -0
- package/dist/storage-Dqnye4KF.d.ts +51 -0
- package/package.json +12 -2
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// src/matrx-oauth.ts
|
|
2
|
+
var MatrxOAuthError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "MatrxOAuthError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var GrantRefusedError = class extends MatrxOAuthError {
|
|
11
|
+
serverCode;
|
|
12
|
+
constructor(serverCode, detail) {
|
|
13
|
+
super("grant_refused", `sign-in refused (${serverCode}): ${detail}`);
|
|
14
|
+
this.name = "GrantRefusedError";
|
|
15
|
+
this.serverCode = serverCode;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var TransientAuthError = class extends MatrxOAuthError {
|
|
19
|
+
constructor(detail) {
|
|
20
|
+
super("transient", detail);
|
|
21
|
+
this.name = "TransientAuthError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var TERMINAL_CODES = /* @__PURE__ */ new Set([
|
|
25
|
+
"invalid_grant",
|
|
26
|
+
"invalid_request",
|
|
27
|
+
"unauthorized_client",
|
|
28
|
+
"refresh_token_not_found",
|
|
29
|
+
"refresh_token_already_used",
|
|
30
|
+
"session_expired",
|
|
31
|
+
"session_not_found"
|
|
32
|
+
]);
|
|
33
|
+
function base64UrlEncode(bytes) {
|
|
34
|
+
let s = "";
|
|
35
|
+
for (let i = 0; i < bytes.byteLength; i++) s += String.fromCharCode(bytes[i]);
|
|
36
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
function base64UrlDecodeToString(input) {
|
|
39
|
+
const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
40
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
41
|
+
const bin = atob(padded);
|
|
42
|
+
const bytes = new Uint8Array(bin.length);
|
|
43
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
44
|
+
return new TextDecoder().decode(bytes);
|
|
45
|
+
}
|
|
46
|
+
function generateCodeVerifier() {
|
|
47
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
48
|
+
}
|
|
49
|
+
async function codeChallengeFor(verifier) {
|
|
50
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
51
|
+
return base64UrlEncode(new Uint8Array(hash));
|
|
52
|
+
}
|
|
53
|
+
function generateState() {
|
|
54
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
55
|
+
}
|
|
56
|
+
function constantTimeEqual(a, b) {
|
|
57
|
+
if (a.length !== b.length) return false;
|
|
58
|
+
let diff = 0;
|
|
59
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
60
|
+
return diff === 0;
|
|
61
|
+
}
|
|
62
|
+
function buildAuthorizeUrl(input) {
|
|
63
|
+
const url = new URL(`${input.authUrl.replace(/\/+$/, "")}/auth/v1/oauth/authorize`);
|
|
64
|
+
url.searchParams.set("response_type", "code");
|
|
65
|
+
url.searchParams.set("client_id", input.clientId);
|
|
66
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
67
|
+
url.searchParams.set("state", input.state);
|
|
68
|
+
url.searchParams.set("code_challenge", input.challenge);
|
|
69
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
70
|
+
url.searchParams.set("scope", input.scope);
|
|
71
|
+
return url.toString();
|
|
72
|
+
}
|
|
73
|
+
function parseCallbackUrl(callbackUrl) {
|
|
74
|
+
const u = new URL(callbackUrl);
|
|
75
|
+
const error = u.searchParams.get("error") || u.searchParams.get("error_description");
|
|
76
|
+
if (error) throw new MatrxOAuthError("callback_error", `OAuth error: ${error}`);
|
|
77
|
+
const code = u.searchParams.get("code");
|
|
78
|
+
const returnedState = u.searchParams.get("state");
|
|
79
|
+
if (!code || !returnedState) throw new MatrxOAuthError("callback_incomplete", "OAuth callback missing code/state");
|
|
80
|
+
return { code, returnedState };
|
|
81
|
+
}
|
|
82
|
+
function personIdFromAccessToken(token) {
|
|
83
|
+
const part = token.split(".")[1];
|
|
84
|
+
if (!part) throw new MatrxOAuthError("invalid_token", "the access token is not a JWT");
|
|
85
|
+
let payload;
|
|
86
|
+
try {
|
|
87
|
+
payload = JSON.parse(base64UrlDecodeToString(part));
|
|
88
|
+
} catch {
|
|
89
|
+
throw new MatrxOAuthError("invalid_token", "the access token payload is unreadable");
|
|
90
|
+
}
|
|
91
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
|
92
|
+
throw new MatrxOAuthError("invalid_token", "the access token has no subject");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
personId: payload.sub,
|
|
96
|
+
email: typeof payload.email === "string" && payload.email.length > 0 ? payload.email : null,
|
|
97
|
+
expiresAtMs: typeof payload.exp === "number" ? payload.exp * 1e3 : null
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function extractErrorMessage(raw) {
|
|
101
|
+
try {
|
|
102
|
+
const j = JSON.parse(raw);
|
|
103
|
+
for (const key of ["error_description", "error_message", "msg", "error", "code"]) {
|
|
104
|
+
const v = j[key];
|
|
105
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function createMatrxOAuth(config, ports) {
|
|
112
|
+
if (!config.clientId) {
|
|
113
|
+
throw new MatrxOAuthError(
|
|
114
|
+
"config_missing",
|
|
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
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!config.authUrl || !config.redirectUri) {
|
|
119
|
+
throw new MatrxOAuthError("config_missing", "The OAuth auth server URL and redirect URI are required.");
|
|
120
|
+
}
|
|
121
|
+
if (typeof crypto === "undefined" || !crypto.subtle) {
|
|
122
|
+
throw new MatrxOAuthError("unsupported_environment", "WebCrypto is not available here, so sign-in cannot run.");
|
|
123
|
+
}
|
|
124
|
+
const base = config.authUrl.replace(/\/+$/, "");
|
|
125
|
+
const scope = config.scope ?? "email profile";
|
|
126
|
+
const refreshLeadMs = config.refreshLeadMs ?? 6e4;
|
|
127
|
+
const attemptTtlMs = config.attemptTtlMs ?? 6e5;
|
|
128
|
+
const timeoutMs = config.requestTimeoutMs ?? 15e3;
|
|
129
|
+
const prefix = config.storageKeyPrefix ?? "matrx-oauth";
|
|
130
|
+
const SESSION_KEY = `${prefix}.session`;
|
|
131
|
+
const ACTIVE_KEY = `${prefix}.active-attempt`;
|
|
132
|
+
const pendingKey = (state) => `${prefix}.pkce.${state}`;
|
|
133
|
+
const storage = ports.storage;
|
|
134
|
+
const pending = ports.pendingStorage ?? ports.storage;
|
|
135
|
+
const now = ports.now ?? Date.now;
|
|
136
|
+
const doFetch = (...args) => (ports.fetch ?? fetch)(...args);
|
|
137
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
138
|
+
const emit = (e) => {
|
|
139
|
+
for (const l of [...listeners]) {
|
|
140
|
+
try {
|
|
141
|
+
l(e);
|
|
142
|
+
} catch {
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
let chain = Promise.resolve();
|
|
147
|
+
const lock = ports.lock ?? ((fn) => {
|
|
148
|
+
const run = chain.then(fn, fn);
|
|
149
|
+
chain = run.catch(() => void 0);
|
|
150
|
+
return run;
|
|
151
|
+
});
|
|
152
|
+
const seal = (s) => ports.seal ? ports.seal.encrypt(s) : Promise.resolve(s);
|
|
153
|
+
const unseal = (s) => ports.seal ? ports.seal.decrypt(s) : Promise.resolve(s);
|
|
154
|
+
async function loadStored() {
|
|
155
|
+
const raw = await storage.getItem(SESSION_KEY);
|
|
156
|
+
if (!raw) return null;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(raw);
|
|
159
|
+
if (parsed?.v !== 1 || typeof parsed.accessToken !== "string" || typeof parsed.refreshToken !== "string") {
|
|
160
|
+
throw new Error("shape");
|
|
161
|
+
}
|
|
162
|
+
return parsed;
|
|
163
|
+
} catch {
|
|
164
|
+
await storage.removeItem(SESSION_KEY);
|
|
165
|
+
emit({ type: "signed_out", reason: "unreadable" });
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const toSession = (s) => ({
|
|
170
|
+
personId: s.personId,
|
|
171
|
+
email: s.email,
|
|
172
|
+
accessToken: s.accessToken,
|
|
173
|
+
expiresAtMs: s.expiresAtMs
|
|
174
|
+
});
|
|
175
|
+
async function clearLocal() {
|
|
176
|
+
const stored = await loadStored();
|
|
177
|
+
await storage.removeItem(SESSION_KEY);
|
|
178
|
+
await pending.removeItem(ACTIVE_KEY);
|
|
179
|
+
return stored?.accessToken ?? null;
|
|
180
|
+
}
|
|
181
|
+
async function tokenRequest(form) {
|
|
182
|
+
let res;
|
|
183
|
+
try {
|
|
184
|
+
res = await doFetch(`${base}/auth/v1/oauth/token`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: {
|
|
187
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
188
|
+
accept: "application/json",
|
|
189
|
+
...config.publishableKey ? { apikey: config.publishableKey } : {}
|
|
190
|
+
},
|
|
191
|
+
body: new URLSearchParams(form).toString(),
|
|
192
|
+
...typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? { signal: AbortSignal.timeout(timeoutMs) } : {}
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
throw new TransientAuthError(`the sign-in service is unreachable: ${String(error).slice(0, 200)}`);
|
|
196
|
+
}
|
|
197
|
+
const type = res.headers.get("content-type") ?? "";
|
|
198
|
+
const text = await res.text().catch(() => "");
|
|
199
|
+
const isExchange = form["grant_type"] === "authorization_code";
|
|
200
|
+
if (!type.includes("json")) {
|
|
201
|
+
throw new TransientAuthError(`the sign-in service answered ${res.status} with ${type || "no content type"}`);
|
|
202
|
+
}
|
|
203
|
+
let body;
|
|
204
|
+
try {
|
|
205
|
+
body = JSON.parse(text);
|
|
206
|
+
} catch {
|
|
207
|
+
throw new TransientAuthError(`the sign-in service answered ${res.status} with unreadable JSON`);
|
|
208
|
+
}
|
|
209
|
+
if (res.ok) {
|
|
210
|
+
const access = body["access_token"];
|
|
211
|
+
if (typeof access !== "string" || access.length === 0) {
|
|
212
|
+
throw new TransientAuthError("the token response carried no access token");
|
|
213
|
+
}
|
|
214
|
+
const refresh = body["refresh_token"];
|
|
215
|
+
const expiresIn = typeof body["expires_in"] === "number" && body["expires_in"] > 0 ? body["expires_in"] : 3600;
|
|
216
|
+
return {
|
|
217
|
+
accessToken: access,
|
|
218
|
+
refreshToken: typeof refresh === "string" && refresh.length > 0 ? refresh : null,
|
|
219
|
+
expiresAtMs: now() + expiresIn * 1e3
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const code = String(body["error"] ?? body["error_code"] ?? res.status);
|
|
223
|
+
const detail = extractErrorMessage(text) ?? text.slice(0, 200);
|
|
224
|
+
if (isExchange) {
|
|
225
|
+
throw new MatrxOAuthError("token_exchange_failed", `Token exchange failed (${res.status}): ${detail}`);
|
|
226
|
+
}
|
|
227
|
+
if ((res.status === 400 || res.status === 401 || res.status === 403) && TERMINAL_CODES.has(code)) {
|
|
228
|
+
throw new GrantRefusedError(code, String(body["error_description"] ?? body["msg"] ?? body["message"] ?? ""));
|
|
229
|
+
}
|
|
230
|
+
throw new TransientAuthError(`the sign-in service answered ${res.status} ${code}: ${detail.slice(0, 200)}`);
|
|
231
|
+
}
|
|
232
|
+
async function commit(tokens, attemptId) {
|
|
233
|
+
if (!tokens.refreshToken) {
|
|
234
|
+
throw new MatrxOAuthError("token_exchange_failed", "Token exchange failed: the sign-in returned no refresh token");
|
|
235
|
+
}
|
|
236
|
+
const claims = personIdFromAccessToken(tokens.accessToken);
|
|
237
|
+
if (ports.beforeCommit) await ports.beforeCommit(tokens.accessToken);
|
|
238
|
+
const sealed = await seal(tokens.refreshToken);
|
|
239
|
+
return lock(async () => {
|
|
240
|
+
if (attemptId !== void 0 && await pending.getItem(ACTIVE_KEY) !== attemptId) {
|
|
241
|
+
throw new MatrxOAuthError("superseded", "This sign-in attempt was superseded by a newer authentication change");
|
|
242
|
+
}
|
|
243
|
+
const stored = {
|
|
244
|
+
v: 1,
|
|
245
|
+
clientId: config.clientId,
|
|
246
|
+
personId: claims.personId,
|
|
247
|
+
email: claims.email,
|
|
248
|
+
accessToken: tokens.accessToken,
|
|
249
|
+
refreshToken: sealed,
|
|
250
|
+
expiresAtMs: tokens.expiresAtMs
|
|
251
|
+
};
|
|
252
|
+
await storage.setItem(SESSION_KEY, JSON.stringify(stored));
|
|
253
|
+
return toSession(stored);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async function signIn() {
|
|
257
|
+
const verifier = generateCodeVerifier();
|
|
258
|
+
const challenge = await codeChallengeFor(verifier);
|
|
259
|
+
const state = generateState();
|
|
260
|
+
const attemptId = `oauth:${state}`;
|
|
261
|
+
const attempt = { verifier, createdAtMs: now(), attemptId };
|
|
262
|
+
await lock(async () => {
|
|
263
|
+
await pending.setItem(pendingKey(state), JSON.stringify(attempt));
|
|
264
|
+
await pending.setItem(ACTIVE_KEY, attemptId);
|
|
265
|
+
});
|
|
266
|
+
try {
|
|
267
|
+
const url = buildAuthorizeUrl({
|
|
268
|
+
authUrl: base,
|
|
269
|
+
clientId: config.clientId,
|
|
270
|
+
redirectUri: config.redirectUri,
|
|
271
|
+
state,
|
|
272
|
+
challenge,
|
|
273
|
+
scope
|
|
274
|
+
});
|
|
275
|
+
const callbackUrl = await ports.openAuthorization(url, { state, redirectUri: config.redirectUri });
|
|
276
|
+
const { code, returnedState } = parseCallbackUrl(callbackUrl);
|
|
277
|
+
if (!constantTimeEqual(returnedState, state)) {
|
|
278
|
+
throw new MatrxOAuthError("state_mismatch", "OAuth state mismatch \u2014 possible CSRF, ignoring response");
|
|
279
|
+
}
|
|
280
|
+
const raw = await pending.getItem(pendingKey(state));
|
|
281
|
+
let recovered = null;
|
|
282
|
+
try {
|
|
283
|
+
recovered = raw ? JSON.parse(raw) : null;
|
|
284
|
+
} catch {
|
|
285
|
+
recovered = null;
|
|
286
|
+
}
|
|
287
|
+
if (!recovered || recovered.verifier !== verifier) {
|
|
288
|
+
throw new MatrxOAuthError("verifier_lost", "Could not recover code_verifier for this sign-in attempt");
|
|
289
|
+
}
|
|
290
|
+
if (now() - recovered.createdAtMs > attemptTtlMs) {
|
|
291
|
+
throw new MatrxOAuthError("attempt_expired", "This sign-in took too long and expired. Start again.");
|
|
292
|
+
}
|
|
293
|
+
const tokens = await tokenRequest({
|
|
294
|
+
grant_type: "authorization_code",
|
|
295
|
+
client_id: config.clientId,
|
|
296
|
+
code,
|
|
297
|
+
code_verifier: recovered.verifier,
|
|
298
|
+
redirect_uri: config.redirectUri
|
|
299
|
+
});
|
|
300
|
+
const session = await commit(tokens, attemptId);
|
|
301
|
+
emit({ type: "signed_in", session });
|
|
302
|
+
return session;
|
|
303
|
+
} finally {
|
|
304
|
+
try {
|
|
305
|
+
await pending.removeItem(pendingKey(state));
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
let inflight = null;
|
|
311
|
+
async function refreshNow(rejected) {
|
|
312
|
+
const stored = await loadStored();
|
|
313
|
+
if (!stored) return null;
|
|
314
|
+
if (stored.clientId !== config.clientId) {
|
|
315
|
+
await lock(clearLocal);
|
|
316
|
+
emit({ type: "signed_out", reason: "client_changed" });
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
if (stored.expiresAtMs - now() > refreshLeadMs && (!rejected || stored.accessToken !== rejected)) {
|
|
320
|
+
return stored.accessToken;
|
|
321
|
+
}
|
|
322
|
+
let refreshToken;
|
|
323
|
+
try {
|
|
324
|
+
refreshToken = await unseal(stored.refreshToken);
|
|
325
|
+
} catch {
|
|
326
|
+
await lock(async () => {
|
|
327
|
+
const again = await loadStored();
|
|
328
|
+
if (again && again.refreshToken === stored.refreshToken) await clearLocal();
|
|
329
|
+
});
|
|
330
|
+
emit({ type: "signed_out", reason: "unreadable" });
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
let tokens;
|
|
334
|
+
try {
|
|
335
|
+
tokens = await tokenRequest({ grant_type: "refresh_token", client_id: config.clientId, refresh_token: refreshToken });
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (error instanceof GrantRefusedError) {
|
|
338
|
+
const outcome = await lock(async () => {
|
|
339
|
+
const current = await loadStored();
|
|
340
|
+
if (!current) return { kind: "gone" };
|
|
341
|
+
if (current.refreshToken !== stored.refreshToken) return { kind: "rotated", token: current.accessToken };
|
|
342
|
+
await clearLocal();
|
|
343
|
+
return { kind: "cleared" };
|
|
344
|
+
});
|
|
345
|
+
if (outcome.kind === "rotated") return outcome.token;
|
|
346
|
+
if (outcome.kind === "cleared") emit({ type: "signed_out", reason: "session_ended" });
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
if (stored.expiresAtMs > now() && stored.accessToken !== rejected) return stored.accessToken;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
const claims = personIdFromAccessToken(tokens.accessToken);
|
|
353
|
+
if (claims.personId !== stored.personId) {
|
|
354
|
+
await lock(clearLocal);
|
|
355
|
+
emit({ type: "signed_out", reason: "person_changed" });
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const sealedNew = tokens.refreshToken ? await seal(tokens.refreshToken) : stored.refreshToken;
|
|
359
|
+
const result = await lock(async () => {
|
|
360
|
+
const current = await loadStored();
|
|
361
|
+
if (!current) return null;
|
|
362
|
+
if (current.refreshToken !== stored.refreshToken) return { token: current.accessToken, session: null };
|
|
363
|
+
const next = {
|
|
364
|
+
...current,
|
|
365
|
+
email: claims.email ?? current.email,
|
|
366
|
+
accessToken: tokens.accessToken,
|
|
367
|
+
refreshToken: sealedNew,
|
|
368
|
+
expiresAtMs: tokens.expiresAtMs
|
|
369
|
+
};
|
|
370
|
+
await storage.setItem(SESSION_KEY, JSON.stringify(next));
|
|
371
|
+
return { token: next.accessToken, session: toSession(next) };
|
|
372
|
+
});
|
|
373
|
+
if (!result) return null;
|
|
374
|
+
if (result.session) emit({ type: "refreshed", session: result.session });
|
|
375
|
+
return result.token;
|
|
376
|
+
}
|
|
377
|
+
function singleFlight(rejected) {
|
|
378
|
+
inflight ??= refreshNow(rejected).finally(() => {
|
|
379
|
+
inflight = null;
|
|
380
|
+
});
|
|
381
|
+
return inflight;
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
signIn,
|
|
385
|
+
async getAccessToken() {
|
|
386
|
+
const stored = await loadStored();
|
|
387
|
+
if (!stored) return null;
|
|
388
|
+
if (stored.clientId === config.clientId && stored.expiresAtMs - now() > refreshLeadMs) return stored.accessToken;
|
|
389
|
+
return singleFlight();
|
|
390
|
+
},
|
|
391
|
+
refresh: (rejectedAccessToken) => singleFlight(rejectedAccessToken),
|
|
392
|
+
async getSession() {
|
|
393
|
+
const stored = await loadStored();
|
|
394
|
+
return stored ? toSession(stored) : null;
|
|
395
|
+
},
|
|
396
|
+
async getPersonId() {
|
|
397
|
+
const stored = await loadStored();
|
|
398
|
+
return stored ? personIdFromAccessToken(stored.accessToken).personId : null;
|
|
399
|
+
},
|
|
400
|
+
async signOut() {
|
|
401
|
+
const access = await lock(clearLocal);
|
|
402
|
+
emit({ type: "signed_out", reason: "user" });
|
|
403
|
+
if (!access) return { revoked: null };
|
|
404
|
+
try {
|
|
405
|
+
const res = await doFetch(`${base}/auth/v1/logout?scope=local`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
authorization: `Bearer ${access}`,
|
|
409
|
+
...config.publishableKey ? { apikey: config.publishableKey } : {}
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
return { revoked: res.ok || res.status === 401 || res.status === 403 || res.status === 404 };
|
|
413
|
+
} catch {
|
|
414
|
+
return { revoked: false };
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
onChange(listener) {
|
|
418
|
+
listeners.add(listener);
|
|
419
|
+
return () => {
|
|
420
|
+
listeners.delete(listener);
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
export {
|
|
426
|
+
GrantRefusedError,
|
|
427
|
+
MatrxOAuthError,
|
|
428
|
+
TransientAuthError,
|
|
429
|
+
buildAuthorizeUrl,
|
|
430
|
+
codeChallengeFor,
|
|
431
|
+
constantTimeEqual,
|
|
432
|
+
createMatrxOAuth,
|
|
433
|
+
generateCodeVerifier,
|
|
434
|
+
generateState,
|
|
435
|
+
parseCallbackUrl,
|
|
436
|
+
personIdFromAccessToken
|
|
437
|
+
};
|
package/dist/next.cjs
CHANGED
|
@@ -29,8 +29,11 @@ __export(next_exports, {
|
|
|
29
29
|
createAuthBudget: () => createAuthBudget,
|
|
30
30
|
createAuthCookie: () => createAuthCookie,
|
|
31
31
|
createNextSupabase: () => createNextSupabase,
|
|
32
|
+
createServerAuth: () => createServerAuth,
|
|
33
|
+
createServiceClient: () => createServiceClient,
|
|
32
34
|
serializeSetCookie: () => serializeSetCookie,
|
|
33
|
-
toResponseCookie: () => toResponseCookie
|
|
35
|
+
toResponseCookie: () => toResponseCookie,
|
|
36
|
+
verifyRequest: () => verifyRequest
|
|
34
37
|
});
|
|
35
38
|
module.exports = __toCommonJS(next_exports);
|
|
36
39
|
|
|
@@ -653,3 +656,132 @@ function createNextSupabase(config) {
|
|
|
653
656
|
middlewareSession
|
|
654
657
|
};
|
|
655
658
|
}
|
|
659
|
+
|
|
660
|
+
// src/server-auth.ts
|
|
661
|
+
var import_supabase_js2 = require("@supabase/supabase-js");
|
|
662
|
+
|
|
663
|
+
// src/claims.ts
|
|
664
|
+
var import_supabase_js = require("@supabase/supabase-js");
|
|
665
|
+
async function getClaimsUser(client, jwt, options) {
|
|
666
|
+
const { data, error } = options ? await client.auth.getClaims(jwt, options) : await client.auth.getClaims(jwt);
|
|
667
|
+
if (error) {
|
|
668
|
+
if (isSignedOutError(error)) return { data: { user: null }, error: null };
|
|
669
|
+
return { data: { user: null }, error };
|
|
670
|
+
}
|
|
671
|
+
if (!data?.claims) return { data: { user: null }, error: null };
|
|
672
|
+
const user = userFromClaims(data.claims);
|
|
673
|
+
if (!user) {
|
|
674
|
+
return {
|
|
675
|
+
data: { user: null },
|
|
676
|
+
error: new import_supabase_js.AuthError("Verified token carries no `sub` claim", 401, "bad_jwt")
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return { data: { user }, error: null };
|
|
680
|
+
}
|
|
681
|
+
var BUDGET_EXHAUSTED_STATUS = 408;
|
|
682
|
+
var BUDGET_EXHAUSTED_CODE = "auth_budget_exhausted";
|
|
683
|
+
function isAuthTransportFailure2(error) {
|
|
684
|
+
if (!error || typeof error !== "object") return false;
|
|
685
|
+
const name = String(error.name ?? "");
|
|
686
|
+
if (name === "AuthRetryableFetchError" || name === "TimeoutError" || name === "AbortError") return true;
|
|
687
|
+
const status = error.status;
|
|
688
|
+
if (status === 0 || status === 504 || status === 503) return true;
|
|
689
|
+
return status === BUDGET_EXHAUSTED_STATUS || error.code === BUDGET_EXHAUSTED_CODE;
|
|
690
|
+
}
|
|
691
|
+
function isSignedOutError(error) {
|
|
692
|
+
return error instanceof import_supabase_js.AuthSessionMissingError || error.name === "AuthSessionMissingError" || error.code === "session_not_found";
|
|
693
|
+
}
|
|
694
|
+
function userFromClaims(claims) {
|
|
695
|
+
if (!claims || typeof claims.sub !== "string") return null;
|
|
696
|
+
return {
|
|
697
|
+
...claims,
|
|
698
|
+
id: claims.sub,
|
|
699
|
+
app_metadata: claims.app_metadata ?? {},
|
|
700
|
+
user_metadata: claims.user_metadata ?? {}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
function accountFlagsFor(claims) {
|
|
704
|
+
if (!claims) return { isGuest: false, hasAccount: false };
|
|
705
|
+
const isGuest = claims.is_anonymous === true;
|
|
706
|
+
return { isGuest, hasAccount: !isGuest };
|
|
707
|
+
}
|
|
708
|
+
var PROJECT_SIGNING_KEY_AUTHORITIES = [
|
|
709
|
+
"db.matrxserver.com",
|
|
710
|
+
"brsgrqvjdzwihsvnfqkf.supabase.co"
|
|
711
|
+
];
|
|
712
|
+
var PROJECT_SIGNING_KEYS = [
|
|
713
|
+
{
|
|
714
|
+
alg: "ES256",
|
|
715
|
+
crv: "P-256",
|
|
716
|
+
ext: true,
|
|
717
|
+
key_ops: ["verify"],
|
|
718
|
+
kid: "a00aed0b-202c-483a-91ff-fc74719a3bb2",
|
|
719
|
+
kty: "EC",
|
|
720
|
+
use: "sig",
|
|
721
|
+
x: "guqOsgjJqGVU6g_K0iIXKrzixlkeXKOqkPUQCconp6U",
|
|
722
|
+
y: "39zQtkzlfQuPf_MtIhYmMdxNQpIR2Pkl11AToywaslY"
|
|
723
|
+
}
|
|
724
|
+
];
|
|
725
|
+
function pinnedSigningKeysFor(supabaseUrl) {
|
|
726
|
+
if (!supabaseUrl) return [];
|
|
727
|
+
let host;
|
|
728
|
+
try {
|
|
729
|
+
host = new URL(supabaseUrl).host.toLowerCase();
|
|
730
|
+
} catch {
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
return PROJECT_SIGNING_KEY_AUTHORITIES.includes(host) ? PROJECT_SIGNING_KEYS : [];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/server-auth.ts
|
|
737
|
+
function createServerAuth(options) {
|
|
738
|
+
const warn = options.warn ?? ((m) => console.warn(m));
|
|
739
|
+
const resolveOnce = async () => {
|
|
740
|
+
const client = await options.createClient();
|
|
741
|
+
const {
|
|
742
|
+
data: { user },
|
|
743
|
+
error
|
|
744
|
+
} = await getClaimsUser(client, void 0, { jwks: { keys: pinnedSigningKeysFor(options.supabaseUrl) } });
|
|
745
|
+
const authUnavailable = !user && error !== null && isAuthTransportFailure2(error);
|
|
746
|
+
if (authUnavailable) {
|
|
747
|
+
warn(
|
|
748
|
+
`[getServerAuth] identity could not be verified on this attempt \u2014 rendering without a user, NOT as signed out. Cause: ${error?.name ?? "unknown"}: ${error?.message ?? ""}`
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
return { isAuthenticated: !!user, user, authUnavailable, ...accountFlagsFor(user) };
|
|
752
|
+
};
|
|
753
|
+
const door = async () => {
|
|
754
|
+
const first = await resolveOnce();
|
|
755
|
+
if (!first.authUnavailable) return first;
|
|
756
|
+
await new Promise((resolve) => setTimeout(resolve, options.retryAfterMs ?? 250));
|
|
757
|
+
const second = await resolveOnce();
|
|
758
|
+
if (!second.authUnavailable) warn("[getServerAuth] identity verified on the retry \u2014 the first attempt's budget was spent.");
|
|
759
|
+
return second;
|
|
760
|
+
};
|
|
761
|
+
return options.cache ? options.cache(door) : door;
|
|
762
|
+
}
|
|
763
|
+
async function verifyRequest(request, options) {
|
|
764
|
+
const header = request.headers.get("Authorization") ?? request.headers.get("authorization");
|
|
765
|
+
if (header?.startsWith("Bearer ")) {
|
|
766
|
+
const token = header.slice(7);
|
|
767
|
+
const client2 = (0, import_supabase_js2.createClient)(options.url, options.publishableKey, {
|
|
768
|
+
global: { headers: { Authorization: `Bearer ${token}` } },
|
|
769
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
770
|
+
});
|
|
771
|
+
const { data: data2, error: error2 } = await getClaimsUser(client2, token, {
|
|
772
|
+
jwks: { keys: pinnedSigningKeysFor(options.url) }
|
|
773
|
+
});
|
|
774
|
+
return { user: data2.user, error: error2 };
|
|
775
|
+
}
|
|
776
|
+
if (!options.cookieClient) return { user: null, error: null };
|
|
777
|
+
const client = await options.cookieClient();
|
|
778
|
+
const { data, error } = await getClaimsUser(client, void 0, { jwks: { keys: pinnedSigningKeysFor(options.url) } });
|
|
779
|
+
return { user: data.user, error };
|
|
780
|
+
}
|
|
781
|
+
function createServiceClient(options) {
|
|
782
|
+
const url = options.url?.trim();
|
|
783
|
+
const key = options.secretKey?.trim();
|
|
784
|
+
if (!url) throw new Error("createServiceClient: the Supabase URL is not configured");
|
|
785
|
+
if (!key) throw new Error("createServiceClient: the secret key (SUPABASE_SECRET_KEY) is not configured");
|
|
786
|
+
return (0, import_supabase_js2.createClient)(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
|
|
787
|
+
}
|
package/dist/next.d.cts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createServerClient, createBrowserClient } from '@supabase/ssr';
|
|
2
2
|
import { b as AuthCookieValue, A as AuthCookie, a as AuthCookieOptions } from './auth-cookie-DWx6Q3xo.cjs';
|
|
3
3
|
export { C as CreateAuthCookieArgs, D as DEFAULT_AUTH_COOKIE_NAME, c as createAuthCookie } from './auth-cookie-DWx6Q3xo.cjs';
|
|
4
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
5
|
+
import { a as ClaimsCapableClient, A as ApiClaimsUser } from './claims-DXdbe3cW.cjs';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* @ai-matrx/data/next — the Next.js Supabase binding.
|
|
@@ -386,4 +388,78 @@ interface NextSupabase<DB extends DatabaseLike> {
|
|
|
386
388
|
}
|
|
387
389
|
declare function createNextSupabase<DB extends DatabaseLike = DatabaseLike>(config: NextSupabaseConfig): NextSupabase<DB>;
|
|
388
390
|
|
|
389
|
-
|
|
391
|
+
/**
|
|
392
|
+
* Server identity doors (moved from the website's `utils/supabase/{getServerAuth,resolveUser,
|
|
393
|
+
* adminClient}.ts`, 2026-10-12). Framework-free: the app injects how it builds its cookie client,
|
|
394
|
+
* and (for Next) React's `cache()` so one request resolves once. Exported from `/next` and `/node`.
|
|
395
|
+
*/
|
|
396
|
+
|
|
397
|
+
interface ServerAuthState {
|
|
398
|
+
/** `true` when the request carries a locally verified access token. */
|
|
399
|
+
isAuthenticated: boolean;
|
|
400
|
+
/** The verified caller (claims, `id` = `sub`), or `null` for guests and unresolvable requests. */
|
|
401
|
+
user: ApiClaimsUser | null;
|
|
402
|
+
/**
|
|
403
|
+
* `true` when no verdict could be REACHED (authority/JWKS unreachable, identity budget spent).
|
|
404
|
+
* The person is NOT signed out: never bounce to sign-in on it. Never set for a token that was
|
|
405
|
+
* judged bad — that is a settled answer, the same line the proxy draws.
|
|
406
|
+
*/
|
|
407
|
+
authUnavailable: boolean;
|
|
408
|
+
/** `true` for a guest (anonymous) session. */
|
|
409
|
+
isGuest: boolean;
|
|
410
|
+
/** `isAuthenticated && !isGuest` — the flag account UI keys on. */
|
|
411
|
+
hasAccount: boolean;
|
|
412
|
+
}
|
|
413
|
+
interface ServerAuthOptions {
|
|
414
|
+
/** A FRESH cookie-bound client per attempt (the identity budget belongs to the client). */
|
|
415
|
+
createClient: () => Promise<ClaimsCapableClient> | ClaimsCapableClient;
|
|
416
|
+
/** The URL the app talks to — decides which pinned keys apply. */
|
|
417
|
+
supabaseUrl: string | null | undefined;
|
|
418
|
+
/** React's `cache` on a Next server, so the whole tree shares one resolve per request. */
|
|
419
|
+
cache?: <F extends (...args: never[]) => unknown>(fn: F) => F;
|
|
420
|
+
/** Wait before the one retry (default 250ms). */
|
|
421
|
+
retryAfterMs?: number;
|
|
422
|
+
/** Where the "could not verify" warnings go (default `console.warn`). */
|
|
423
|
+
warn?: (message: string) => void;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Build THE server identity door: claims verified locally, asked twice before it hands anyone
|
|
427
|
+
* "could not verify" (that is almost always one cold first load spending its budget).
|
|
428
|
+
*
|
|
429
|
+
* export const getServerAuth = createServerAuth({ createClient, supabaseUrl, cache });
|
|
430
|
+
*/
|
|
431
|
+
declare function createServerAuth(options: ServerAuthOptions): () => Promise<ServerAuthState>;
|
|
432
|
+
/** The bit of a request this needs: its headers (a `Request`, a `NextRequest`, a Node shim). */
|
|
433
|
+
interface RequestLike {
|
|
434
|
+
headers: {
|
|
435
|
+
get(name: string): string | null;
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
interface VerifyRequestOptions {
|
|
439
|
+
url: string;
|
|
440
|
+
/** The publishable key (`sb_publishable_*`). */
|
|
441
|
+
publishableKey: string;
|
|
442
|
+
/** The app's cookie-bound client, used when the request carries no Bearer token. */
|
|
443
|
+
cookieClient?: () => Promise<ClaimsCapableClient> | ClaimsCapableClient;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Who is calling this API route: a Bearer token (public/mobile/extension/desktop clients) or the
|
|
447
|
+
* session cookie (browser). `user` is null when the caller is signed out OR could not be verified;
|
|
448
|
+
* `error` tells the two apart (null = settled signed out).
|
|
449
|
+
*/
|
|
450
|
+
declare function verifyRequest(request: RequestLike, options: VerifyRequestOptions): Promise<{
|
|
451
|
+
user: ApiClaimsUser | null;
|
|
452
|
+
error: Error | null;
|
|
453
|
+
}>;
|
|
454
|
+
/**
|
|
455
|
+
* THE service (secret-key) client — server-only, bypasses row security. The package's browser
|
|
456
|
+
* clients refuse a secret key by design (`SecretKeyRefusedError`); this is the one door for it.
|
|
457
|
+
* Never ship it to a browser bundle: it lives in `/node` and `/next` only.
|
|
458
|
+
*/
|
|
459
|
+
declare function createServiceClient<Database = unknown>(options: {
|
|
460
|
+
url: string | null | undefined;
|
|
461
|
+
/** `sb_secret_*` (the legacy service-role JWT is banned). */
|
|
462
|
+
secretKey: string | null | undefined;
|
|
463
|
+
}): SupabaseClient<Database>;
|
|
464
|
+
|
|
465
|
+
export { AMBIGUOUS_AUTH_COOKIE_HEADER, AUTH_COOKIE_RESPONSE_HEADERS, AUTH_REFRESH_CEILING_MS, type AuthBudget, AuthCookie, AuthCookieOptions, AuthCookieValue, type CookieToSet, type DatabaseLike, type MiddlewareResponse, type MiddlewareSession, type MiddlewareSessionArgs, MissingSupabaseIdentityError, type NextBrowserClient, type NextServerClient, type NextSupabase, type NextSupabaseConfig, type RequestCookieJar, type RequestLike, type ResponseCookie, type ResponseCookieJar, SPLIT_COOKIE_JAR_HEADER, type ServerAuthOptions, type ServerAuthState, type ServerCookieStore, type VerifyRequestOptions, createAuthBudget, createNextSupabase, createServerAuth, createServiceClient, serializeSetCookie, toResponseCookie, verifyRequest };
|