@ai-matrx/auth 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -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 +358 -10
- package/dist/organizations.d.cts +359 -3
- package/dist/organizations.d.ts +359 -3
- package/dist/organizations.js +358 -10
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/storage-Dqnye4KF.d.cts +51 -0
- package/dist/storage-Dqnye4KF.d.ts +51 -0
- package/package.json +12 -2
package/dist/index.js
CHANGED
|
@@ -180,24 +180,161 @@ function createAuthClient(options) {
|
|
|
180
180
|
if (options.detectSessionInUrl !== void 0) auth["detectSessionInUrl"] = options.detectSessionInUrl;
|
|
181
181
|
return createClient(options.url, options.key, { auth });
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
// src/claims.ts
|
|
185
|
+
import {
|
|
186
|
+
AuthError,
|
|
187
|
+
AuthSessionMissingError
|
|
188
|
+
} from "@supabase/supabase-js";
|
|
189
|
+
async function getClaimsUser(client, jwt, options) {
|
|
190
|
+
const { data, error } = options ? await client.auth.getClaims(jwt, options) : await client.auth.getClaims(jwt);
|
|
191
|
+
if (error) {
|
|
192
|
+
if (isSignedOutError(error)) return { data: { user: null }, error: null };
|
|
193
|
+
return { data: { user: null }, error };
|
|
194
|
+
}
|
|
195
|
+
if (!data?.claims) return { data: { user: null }, error: null };
|
|
196
|
+
const user = userFromClaims(data.claims);
|
|
197
|
+
if (!user) {
|
|
198
|
+
return {
|
|
199
|
+
data: { user: null },
|
|
200
|
+
error: new AuthError("Verified token carries no `sub` claim", 401, "bad_jwt")
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return { data: { user }, error: null };
|
|
204
|
+
}
|
|
205
|
+
var BUDGET_EXHAUSTED_STATUS = 408;
|
|
206
|
+
var BUDGET_EXHAUSTED_CODE = "auth_budget_exhausted";
|
|
207
|
+
function isAuthTransportFailure(error) {
|
|
208
|
+
if (!error || typeof error !== "object") return false;
|
|
209
|
+
const name = String(error.name ?? "");
|
|
210
|
+
if (name === "AuthRetryableFetchError" || name === "TimeoutError" || name === "AbortError") return true;
|
|
211
|
+
const status = error.status;
|
|
212
|
+
if (status === 0 || status === 504 || status === 503) return true;
|
|
213
|
+
return status === BUDGET_EXHAUSTED_STATUS || error.code === BUDGET_EXHAUSTED_CODE;
|
|
214
|
+
}
|
|
215
|
+
function isSignedOutError(error) {
|
|
216
|
+
return error instanceof AuthSessionMissingError || error.name === "AuthSessionMissingError" || error.code === "session_not_found";
|
|
217
|
+
}
|
|
218
|
+
function userFromClaims(claims) {
|
|
219
|
+
if (!claims || typeof claims.sub !== "string") return null;
|
|
220
|
+
return {
|
|
221
|
+
...claims,
|
|
222
|
+
id: claims.sub,
|
|
223
|
+
app_metadata: claims.app_metadata ?? {},
|
|
224
|
+
user_metadata: claims.user_metadata ?? {}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function accountFlagsFor(claims) {
|
|
228
|
+
if (!claims) return { isGuest: false, hasAccount: false };
|
|
229
|
+
const isGuest = claims.is_anonymous === true;
|
|
230
|
+
return { isGuest, hasAccount: !isGuest };
|
|
231
|
+
}
|
|
232
|
+
var PROJECT_SIGNING_KEY_AUTHORITIES = [
|
|
233
|
+
"db.matrxserver.com",
|
|
234
|
+
"brsgrqvjdzwihsvnfqkf.supabase.co"
|
|
235
|
+
];
|
|
236
|
+
var PROJECT_SIGNING_KEYS = [
|
|
237
|
+
{
|
|
238
|
+
alg: "ES256",
|
|
239
|
+
crv: "P-256",
|
|
240
|
+
ext: true,
|
|
241
|
+
key_ops: ["verify"],
|
|
242
|
+
kid: "a00aed0b-202c-483a-91ff-fc74719a3bb2",
|
|
243
|
+
kty: "EC",
|
|
244
|
+
use: "sig",
|
|
245
|
+
x: "guqOsgjJqGVU6g_K0iIXKrzixlkeXKOqkPUQCconp6U",
|
|
246
|
+
y: "39zQtkzlfQuPf_MtIhYmMdxNQpIR2Pkl11AToywaslY"
|
|
247
|
+
}
|
|
248
|
+
];
|
|
249
|
+
function pinnedSigningKeysFor(supabaseUrl) {
|
|
250
|
+
if (!supabaseUrl) return [];
|
|
251
|
+
let host;
|
|
252
|
+
try {
|
|
253
|
+
host = new URL(supabaseUrl).host.toLowerCase();
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
return PROJECT_SIGNING_KEY_AUTHORITIES.includes(host) ? PROJECT_SIGNING_KEYS : [];
|
|
258
|
+
}
|
|
259
|
+
function isUnverifiableTokenRefusal(result) {
|
|
260
|
+
if (result.status !== 401) return false;
|
|
261
|
+
if (result.error?.code !== "PGRST301") return false;
|
|
262
|
+
return !/expired/i.test(result.error?.message ?? "");
|
|
263
|
+
}
|
|
264
|
+
function isUnverifiableTokenError(error) {
|
|
265
|
+
if (!error) return false;
|
|
266
|
+
const name = String(error.name ?? "");
|
|
267
|
+
if (name === "AuthRetryableFetchError" || name === "TimeoutError" || name === "AbortError") return false;
|
|
268
|
+
if (name === "AuthSessionMissingError" || error.code === "session_not_found") return false;
|
|
269
|
+
const status = error.status;
|
|
270
|
+
if (status === 0 || status === 408 || status === 503 || status === 504) return false;
|
|
271
|
+
if (/expired/i.test(error.message ?? "")) return false;
|
|
272
|
+
return error.code === "bad_jwt" || name === "AuthInvalidJwtError";
|
|
273
|
+
}
|
|
274
|
+
async function confirmUnverifiableSession(auth, supabaseUrl) {
|
|
275
|
+
try {
|
|
276
|
+
const { error } = await auth.getClaims(void 0, { jwks: { keys: pinnedSigningKeysFor(supabaseUrl) } });
|
|
277
|
+
return isUnverifiableTokenError(error);
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function createClaimsCache(client, options) {
|
|
283
|
+
const ttl = options.ttlMs ?? 2e3;
|
|
284
|
+
let cached = null;
|
|
285
|
+
let inflight = null;
|
|
286
|
+
let listening = false;
|
|
287
|
+
return () => {
|
|
288
|
+
if (!listening) {
|
|
289
|
+
listening = true;
|
|
290
|
+
client.auth.onAuthStateChange((event) => {
|
|
291
|
+
if (event === "SIGNED_OUT" || event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
|
292
|
+
cached = null;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (cached && cached.expiresAt > Date.now()) return Promise.resolve(cached.value);
|
|
297
|
+
if (inflight) return inflight;
|
|
298
|
+
inflight = getClaimsUser(client, void 0, { jwks: { keys: pinnedSigningKeysFor(options.supabaseUrl) } }).then((result) => {
|
|
299
|
+
if (isUnverifiableTokenError(result.error)) options.onUnverifiable?.();
|
|
300
|
+
cached = { value: result, expiresAt: Date.now() + ttl };
|
|
301
|
+
return result;
|
|
302
|
+
}).finally(() => {
|
|
303
|
+
inflight = null;
|
|
304
|
+
});
|
|
305
|
+
return inflight;
|
|
306
|
+
};
|
|
307
|
+
}
|
|
183
308
|
export {
|
|
309
|
+
PROJECT_SIGNING_KEYS,
|
|
310
|
+
PROJECT_SIGNING_KEY_AUTHORITIES,
|
|
184
311
|
SecretKeyRefusedError,
|
|
185
312
|
SignInError,
|
|
186
313
|
StorageUnavailableError,
|
|
314
|
+
accountFlagsFor,
|
|
187
315
|
browserStorage,
|
|
316
|
+
confirmUnverifiableSession,
|
|
188
317
|
createAuthClient,
|
|
318
|
+
createClaimsCache,
|
|
189
319
|
extensionStorage,
|
|
320
|
+
getClaimsUser,
|
|
190
321
|
getPerson,
|
|
191
322
|
getSession,
|
|
192
323
|
invalidatePerson,
|
|
324
|
+
isAuthTransportFailure,
|
|
325
|
+
isSignedOutError,
|
|
326
|
+
isUnverifiableTokenError,
|
|
327
|
+
isUnverifiableTokenRefusal,
|
|
193
328
|
keyValueStorage,
|
|
194
329
|
memoryStorage,
|
|
195
330
|
onSessionChange,
|
|
331
|
+
pinnedSigningKeysFor,
|
|
196
332
|
refreshSession,
|
|
197
333
|
sessionCredentials,
|
|
198
334
|
signInWithOAuth,
|
|
199
335
|
signInWithPassword,
|
|
200
336
|
signOut,
|
|
201
337
|
updatePerson,
|
|
202
|
-
userCredentials
|
|
338
|
+
userCredentials,
|
|
339
|
+
userFromClaims
|
|
203
340
|
};
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/matrx-oauth.ts
|
|
21
|
+
var matrx_oauth_exports = {};
|
|
22
|
+
__export(matrx_oauth_exports, {
|
|
23
|
+
GrantRefusedError: () => GrantRefusedError,
|
|
24
|
+
MatrxOAuthError: () => MatrxOAuthError,
|
|
25
|
+
TransientAuthError: () => TransientAuthError,
|
|
26
|
+
buildAuthorizeUrl: () => buildAuthorizeUrl,
|
|
27
|
+
codeChallengeFor: () => codeChallengeFor,
|
|
28
|
+
constantTimeEqual: () => constantTimeEqual,
|
|
29
|
+
createMatrxOAuth: () => createMatrxOAuth,
|
|
30
|
+
generateCodeVerifier: () => generateCodeVerifier,
|
|
31
|
+
generateState: () => generateState,
|
|
32
|
+
parseCallbackUrl: () => parseCallbackUrl,
|
|
33
|
+
personIdFromAccessToken: () => personIdFromAccessToken
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(matrx_oauth_exports);
|
|
36
|
+
var MatrxOAuthError = class extends Error {
|
|
37
|
+
code;
|
|
38
|
+
constructor(code, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "MatrxOAuthError";
|
|
41
|
+
this.code = code;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var GrantRefusedError = class extends MatrxOAuthError {
|
|
45
|
+
serverCode;
|
|
46
|
+
constructor(serverCode, detail) {
|
|
47
|
+
super("grant_refused", `sign-in refused (${serverCode}): ${detail}`);
|
|
48
|
+
this.name = "GrantRefusedError";
|
|
49
|
+
this.serverCode = serverCode;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var TransientAuthError = class extends MatrxOAuthError {
|
|
53
|
+
constructor(detail) {
|
|
54
|
+
super("transient", detail);
|
|
55
|
+
this.name = "TransientAuthError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var TERMINAL_CODES = /* @__PURE__ */ new Set([
|
|
59
|
+
"invalid_grant",
|
|
60
|
+
"invalid_request",
|
|
61
|
+
"unauthorized_client",
|
|
62
|
+
"refresh_token_not_found",
|
|
63
|
+
"refresh_token_already_used",
|
|
64
|
+
"session_expired",
|
|
65
|
+
"session_not_found"
|
|
66
|
+
]);
|
|
67
|
+
function base64UrlEncode(bytes) {
|
|
68
|
+
let s = "";
|
|
69
|
+
for (let i = 0; i < bytes.byteLength; i++) s += String.fromCharCode(bytes[i]);
|
|
70
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
71
|
+
}
|
|
72
|
+
function base64UrlDecodeToString(input) {
|
|
73
|
+
const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
74
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
75
|
+
const bin = atob(padded);
|
|
76
|
+
const bytes = new Uint8Array(bin.length);
|
|
77
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
78
|
+
return new TextDecoder().decode(bytes);
|
|
79
|
+
}
|
|
80
|
+
function generateCodeVerifier() {
|
|
81
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
82
|
+
}
|
|
83
|
+
async function codeChallengeFor(verifier) {
|
|
84
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
85
|
+
return base64UrlEncode(new Uint8Array(hash));
|
|
86
|
+
}
|
|
87
|
+
function generateState() {
|
|
88
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
|
|
89
|
+
}
|
|
90
|
+
function constantTimeEqual(a, b) {
|
|
91
|
+
if (a.length !== b.length) return false;
|
|
92
|
+
let diff = 0;
|
|
93
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
94
|
+
return diff === 0;
|
|
95
|
+
}
|
|
96
|
+
function buildAuthorizeUrl(input) {
|
|
97
|
+
const url = new URL(`${input.authUrl.replace(/\/+$/, "")}/auth/v1/oauth/authorize`);
|
|
98
|
+
url.searchParams.set("response_type", "code");
|
|
99
|
+
url.searchParams.set("client_id", input.clientId);
|
|
100
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
101
|
+
url.searchParams.set("state", input.state);
|
|
102
|
+
url.searchParams.set("code_challenge", input.challenge);
|
|
103
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
104
|
+
url.searchParams.set("scope", input.scope);
|
|
105
|
+
return url.toString();
|
|
106
|
+
}
|
|
107
|
+
function parseCallbackUrl(callbackUrl) {
|
|
108
|
+
const u = new URL(callbackUrl);
|
|
109
|
+
const error = u.searchParams.get("error") || u.searchParams.get("error_description");
|
|
110
|
+
if (error) throw new MatrxOAuthError("callback_error", `OAuth error: ${error}`);
|
|
111
|
+
const code = u.searchParams.get("code");
|
|
112
|
+
const returnedState = u.searchParams.get("state");
|
|
113
|
+
if (!code || !returnedState) throw new MatrxOAuthError("callback_incomplete", "OAuth callback missing code/state");
|
|
114
|
+
return { code, returnedState };
|
|
115
|
+
}
|
|
116
|
+
function personIdFromAccessToken(token) {
|
|
117
|
+
const part = token.split(".")[1];
|
|
118
|
+
if (!part) throw new MatrxOAuthError("invalid_token", "the access token is not a JWT");
|
|
119
|
+
let payload;
|
|
120
|
+
try {
|
|
121
|
+
payload = JSON.parse(base64UrlDecodeToString(part));
|
|
122
|
+
} catch {
|
|
123
|
+
throw new MatrxOAuthError("invalid_token", "the access token payload is unreadable");
|
|
124
|
+
}
|
|
125
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
|
126
|
+
throw new MatrxOAuthError("invalid_token", "the access token has no subject");
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
personId: payload.sub,
|
|
130
|
+
email: typeof payload.email === "string" && payload.email.length > 0 ? payload.email : null,
|
|
131
|
+
expiresAtMs: typeof payload.exp === "number" ? payload.exp * 1e3 : null
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function extractErrorMessage(raw) {
|
|
135
|
+
try {
|
|
136
|
+
const j = JSON.parse(raw);
|
|
137
|
+
for (const key of ["error_description", "error_message", "msg", "error", "code"]) {
|
|
138
|
+
const v = j[key];
|
|
139
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
function createMatrxOAuth(config, ports) {
|
|
146
|
+
if (!config.clientId) {
|
|
147
|
+
throw new MatrxOAuthError(
|
|
148
|
+
"config_missing",
|
|
149
|
+
"The OAuth client id is not set. Register this app as a public PKCE client in the Matrx auth server, then pass its client id."
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!config.authUrl || !config.redirectUri) {
|
|
153
|
+
throw new MatrxOAuthError("config_missing", "The OAuth auth server URL and redirect URI are required.");
|
|
154
|
+
}
|
|
155
|
+
if (typeof crypto === "undefined" || !crypto.subtle) {
|
|
156
|
+
throw new MatrxOAuthError("unsupported_environment", "WebCrypto is not available here, so sign-in cannot run.");
|
|
157
|
+
}
|
|
158
|
+
const base = config.authUrl.replace(/\/+$/, "");
|
|
159
|
+
const scope = config.scope ?? "email profile";
|
|
160
|
+
const refreshLeadMs = config.refreshLeadMs ?? 6e4;
|
|
161
|
+
const attemptTtlMs = config.attemptTtlMs ?? 6e5;
|
|
162
|
+
const timeoutMs = config.requestTimeoutMs ?? 15e3;
|
|
163
|
+
const prefix = config.storageKeyPrefix ?? "matrx-oauth";
|
|
164
|
+
const SESSION_KEY = `${prefix}.session`;
|
|
165
|
+
const ACTIVE_KEY = `${prefix}.active-attempt`;
|
|
166
|
+
const pendingKey = (state) => `${prefix}.pkce.${state}`;
|
|
167
|
+
const storage = ports.storage;
|
|
168
|
+
const pending = ports.pendingStorage ?? ports.storage;
|
|
169
|
+
const now = ports.now ?? Date.now;
|
|
170
|
+
const doFetch = (...args) => (ports.fetch ?? fetch)(...args);
|
|
171
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
172
|
+
const emit = (e) => {
|
|
173
|
+
for (const l of [...listeners]) {
|
|
174
|
+
try {
|
|
175
|
+
l(e);
|
|
176
|
+
} catch {
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
let chain = Promise.resolve();
|
|
181
|
+
const lock = ports.lock ?? ((fn) => {
|
|
182
|
+
const run = chain.then(fn, fn);
|
|
183
|
+
chain = run.catch(() => void 0);
|
|
184
|
+
return run;
|
|
185
|
+
});
|
|
186
|
+
const seal = (s) => ports.seal ? ports.seal.encrypt(s) : Promise.resolve(s);
|
|
187
|
+
const unseal = (s) => ports.seal ? ports.seal.decrypt(s) : Promise.resolve(s);
|
|
188
|
+
async function loadStored() {
|
|
189
|
+
const raw = await storage.getItem(SESSION_KEY);
|
|
190
|
+
if (!raw) return null;
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(raw);
|
|
193
|
+
if (parsed?.v !== 1 || typeof parsed.accessToken !== "string" || typeof parsed.refreshToken !== "string") {
|
|
194
|
+
throw new Error("shape");
|
|
195
|
+
}
|
|
196
|
+
return parsed;
|
|
197
|
+
} catch {
|
|
198
|
+
await storage.removeItem(SESSION_KEY);
|
|
199
|
+
emit({ type: "signed_out", reason: "unreadable" });
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const toSession = (s) => ({
|
|
204
|
+
personId: s.personId,
|
|
205
|
+
email: s.email,
|
|
206
|
+
accessToken: s.accessToken,
|
|
207
|
+
expiresAtMs: s.expiresAtMs
|
|
208
|
+
});
|
|
209
|
+
async function clearLocal() {
|
|
210
|
+
const stored = await loadStored();
|
|
211
|
+
await storage.removeItem(SESSION_KEY);
|
|
212
|
+
await pending.removeItem(ACTIVE_KEY);
|
|
213
|
+
return stored?.accessToken ?? null;
|
|
214
|
+
}
|
|
215
|
+
async function tokenRequest(form) {
|
|
216
|
+
let res;
|
|
217
|
+
try {
|
|
218
|
+
res = await doFetch(`${base}/auth/v1/oauth/token`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: {
|
|
221
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
222
|
+
accept: "application/json",
|
|
223
|
+
...config.publishableKey ? { apikey: config.publishableKey } : {}
|
|
224
|
+
},
|
|
225
|
+
body: new URLSearchParams(form).toString(),
|
|
226
|
+
...typeof AbortSignal !== "undefined" && "timeout" in AbortSignal ? { signal: AbortSignal.timeout(timeoutMs) } : {}
|
|
227
|
+
});
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new TransientAuthError(`the sign-in service is unreachable: ${String(error).slice(0, 200)}`);
|
|
230
|
+
}
|
|
231
|
+
const type = res.headers.get("content-type") ?? "";
|
|
232
|
+
const text = await res.text().catch(() => "");
|
|
233
|
+
const isExchange = form["grant_type"] === "authorization_code";
|
|
234
|
+
if (!type.includes("json")) {
|
|
235
|
+
throw new TransientAuthError(`the sign-in service answered ${res.status} with ${type || "no content type"}`);
|
|
236
|
+
}
|
|
237
|
+
let body;
|
|
238
|
+
try {
|
|
239
|
+
body = JSON.parse(text);
|
|
240
|
+
} catch {
|
|
241
|
+
throw new TransientAuthError(`the sign-in service answered ${res.status} with unreadable JSON`);
|
|
242
|
+
}
|
|
243
|
+
if (res.ok) {
|
|
244
|
+
const access = body["access_token"];
|
|
245
|
+
if (typeof access !== "string" || access.length === 0) {
|
|
246
|
+
throw new TransientAuthError("the token response carried no access token");
|
|
247
|
+
}
|
|
248
|
+
const refresh = body["refresh_token"];
|
|
249
|
+
const expiresIn = typeof body["expires_in"] === "number" && body["expires_in"] > 0 ? body["expires_in"] : 3600;
|
|
250
|
+
return {
|
|
251
|
+
accessToken: access,
|
|
252
|
+
refreshToken: typeof refresh === "string" && refresh.length > 0 ? refresh : null,
|
|
253
|
+
expiresAtMs: now() + expiresIn * 1e3
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const code = String(body["error"] ?? body["error_code"] ?? res.status);
|
|
257
|
+
const detail = extractErrorMessage(text) ?? text.slice(0, 200);
|
|
258
|
+
if (isExchange) {
|
|
259
|
+
throw new MatrxOAuthError("token_exchange_failed", `Token exchange failed (${res.status}): ${detail}`);
|
|
260
|
+
}
|
|
261
|
+
if ((res.status === 400 || res.status === 401 || res.status === 403) && TERMINAL_CODES.has(code)) {
|
|
262
|
+
throw new GrantRefusedError(code, String(body["error_description"] ?? body["msg"] ?? body["message"] ?? ""));
|
|
263
|
+
}
|
|
264
|
+
throw new TransientAuthError(`the sign-in service answered ${res.status} ${code}: ${detail.slice(0, 200)}`);
|
|
265
|
+
}
|
|
266
|
+
async function commit(tokens, attemptId) {
|
|
267
|
+
if (!tokens.refreshToken) {
|
|
268
|
+
throw new MatrxOAuthError("token_exchange_failed", "Token exchange failed: the sign-in returned no refresh token");
|
|
269
|
+
}
|
|
270
|
+
const claims = personIdFromAccessToken(tokens.accessToken);
|
|
271
|
+
if (ports.beforeCommit) await ports.beforeCommit(tokens.accessToken);
|
|
272
|
+
const sealed = await seal(tokens.refreshToken);
|
|
273
|
+
return lock(async () => {
|
|
274
|
+
if (attemptId !== void 0 && await pending.getItem(ACTIVE_KEY) !== attemptId) {
|
|
275
|
+
throw new MatrxOAuthError("superseded", "This sign-in attempt was superseded by a newer authentication change");
|
|
276
|
+
}
|
|
277
|
+
const stored = {
|
|
278
|
+
v: 1,
|
|
279
|
+
clientId: config.clientId,
|
|
280
|
+
personId: claims.personId,
|
|
281
|
+
email: claims.email,
|
|
282
|
+
accessToken: tokens.accessToken,
|
|
283
|
+
refreshToken: sealed,
|
|
284
|
+
expiresAtMs: tokens.expiresAtMs
|
|
285
|
+
};
|
|
286
|
+
await storage.setItem(SESSION_KEY, JSON.stringify(stored));
|
|
287
|
+
return toSession(stored);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
async function signIn() {
|
|
291
|
+
const verifier = generateCodeVerifier();
|
|
292
|
+
const challenge = await codeChallengeFor(verifier);
|
|
293
|
+
const state = generateState();
|
|
294
|
+
const attemptId = `oauth:${state}`;
|
|
295
|
+
const attempt = { verifier, createdAtMs: now(), attemptId };
|
|
296
|
+
await lock(async () => {
|
|
297
|
+
await pending.setItem(pendingKey(state), JSON.stringify(attempt));
|
|
298
|
+
await pending.setItem(ACTIVE_KEY, attemptId);
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
const url = buildAuthorizeUrl({
|
|
302
|
+
authUrl: base,
|
|
303
|
+
clientId: config.clientId,
|
|
304
|
+
redirectUri: config.redirectUri,
|
|
305
|
+
state,
|
|
306
|
+
challenge,
|
|
307
|
+
scope
|
|
308
|
+
});
|
|
309
|
+
const callbackUrl = await ports.openAuthorization(url, { state, redirectUri: config.redirectUri });
|
|
310
|
+
const { code, returnedState } = parseCallbackUrl(callbackUrl);
|
|
311
|
+
if (!constantTimeEqual(returnedState, state)) {
|
|
312
|
+
throw new MatrxOAuthError("state_mismatch", "OAuth state mismatch \u2014 possible CSRF, ignoring response");
|
|
313
|
+
}
|
|
314
|
+
const raw = await pending.getItem(pendingKey(state));
|
|
315
|
+
let recovered = null;
|
|
316
|
+
try {
|
|
317
|
+
recovered = raw ? JSON.parse(raw) : null;
|
|
318
|
+
} catch {
|
|
319
|
+
recovered = null;
|
|
320
|
+
}
|
|
321
|
+
if (!recovered || recovered.verifier !== verifier) {
|
|
322
|
+
throw new MatrxOAuthError("verifier_lost", "Could not recover code_verifier for this sign-in attempt");
|
|
323
|
+
}
|
|
324
|
+
if (now() - recovered.createdAtMs > attemptTtlMs) {
|
|
325
|
+
throw new MatrxOAuthError("attempt_expired", "This sign-in took too long and expired. Start again.");
|
|
326
|
+
}
|
|
327
|
+
const tokens = await tokenRequest({
|
|
328
|
+
grant_type: "authorization_code",
|
|
329
|
+
client_id: config.clientId,
|
|
330
|
+
code,
|
|
331
|
+
code_verifier: recovered.verifier,
|
|
332
|
+
redirect_uri: config.redirectUri
|
|
333
|
+
});
|
|
334
|
+
const session = await commit(tokens, attemptId);
|
|
335
|
+
emit({ type: "signed_in", session });
|
|
336
|
+
return session;
|
|
337
|
+
} finally {
|
|
338
|
+
try {
|
|
339
|
+
await pending.removeItem(pendingKey(state));
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
let inflight = null;
|
|
345
|
+
async function refreshNow(rejected) {
|
|
346
|
+
const stored = await loadStored();
|
|
347
|
+
if (!stored) return null;
|
|
348
|
+
if (stored.clientId !== config.clientId) {
|
|
349
|
+
await lock(clearLocal);
|
|
350
|
+
emit({ type: "signed_out", reason: "client_changed" });
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
if (stored.expiresAtMs - now() > refreshLeadMs && (!rejected || stored.accessToken !== rejected)) {
|
|
354
|
+
return stored.accessToken;
|
|
355
|
+
}
|
|
356
|
+
let refreshToken;
|
|
357
|
+
try {
|
|
358
|
+
refreshToken = await unseal(stored.refreshToken);
|
|
359
|
+
} catch {
|
|
360
|
+
await lock(async () => {
|
|
361
|
+
const again = await loadStored();
|
|
362
|
+
if (again && again.refreshToken === stored.refreshToken) await clearLocal();
|
|
363
|
+
});
|
|
364
|
+
emit({ type: "signed_out", reason: "unreadable" });
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
let tokens;
|
|
368
|
+
try {
|
|
369
|
+
tokens = await tokenRequest({ grant_type: "refresh_token", client_id: config.clientId, refresh_token: refreshToken });
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error instanceof GrantRefusedError) {
|
|
372
|
+
const outcome = await lock(async () => {
|
|
373
|
+
const current = await loadStored();
|
|
374
|
+
if (!current) return { kind: "gone" };
|
|
375
|
+
if (current.refreshToken !== stored.refreshToken) return { kind: "rotated", token: current.accessToken };
|
|
376
|
+
await clearLocal();
|
|
377
|
+
return { kind: "cleared" };
|
|
378
|
+
});
|
|
379
|
+
if (outcome.kind === "rotated") return outcome.token;
|
|
380
|
+
if (outcome.kind === "cleared") emit({ type: "signed_out", reason: "session_ended" });
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
if (stored.expiresAtMs > now() && stored.accessToken !== rejected) return stored.accessToken;
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
const claims = personIdFromAccessToken(tokens.accessToken);
|
|
387
|
+
if (claims.personId !== stored.personId) {
|
|
388
|
+
await lock(clearLocal);
|
|
389
|
+
emit({ type: "signed_out", reason: "person_changed" });
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
const sealedNew = tokens.refreshToken ? await seal(tokens.refreshToken) : stored.refreshToken;
|
|
393
|
+
const result = await lock(async () => {
|
|
394
|
+
const current = await loadStored();
|
|
395
|
+
if (!current) return null;
|
|
396
|
+
if (current.refreshToken !== stored.refreshToken) return { token: current.accessToken, session: null };
|
|
397
|
+
const next = {
|
|
398
|
+
...current,
|
|
399
|
+
email: claims.email ?? current.email,
|
|
400
|
+
accessToken: tokens.accessToken,
|
|
401
|
+
refreshToken: sealedNew,
|
|
402
|
+
expiresAtMs: tokens.expiresAtMs
|
|
403
|
+
};
|
|
404
|
+
await storage.setItem(SESSION_KEY, JSON.stringify(next));
|
|
405
|
+
return { token: next.accessToken, session: toSession(next) };
|
|
406
|
+
});
|
|
407
|
+
if (!result) return null;
|
|
408
|
+
if (result.session) emit({ type: "refreshed", session: result.session });
|
|
409
|
+
return result.token;
|
|
410
|
+
}
|
|
411
|
+
function singleFlight(rejected) {
|
|
412
|
+
inflight ??= refreshNow(rejected).finally(() => {
|
|
413
|
+
inflight = null;
|
|
414
|
+
});
|
|
415
|
+
return inflight;
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
signIn,
|
|
419
|
+
async getAccessToken() {
|
|
420
|
+
const stored = await loadStored();
|
|
421
|
+
if (!stored) return null;
|
|
422
|
+
if (stored.clientId === config.clientId && stored.expiresAtMs - now() > refreshLeadMs) return stored.accessToken;
|
|
423
|
+
return singleFlight();
|
|
424
|
+
},
|
|
425
|
+
refresh: (rejectedAccessToken) => singleFlight(rejectedAccessToken),
|
|
426
|
+
async getSession() {
|
|
427
|
+
const stored = await loadStored();
|
|
428
|
+
return stored ? toSession(stored) : null;
|
|
429
|
+
},
|
|
430
|
+
async getPersonId() {
|
|
431
|
+
const stored = await loadStored();
|
|
432
|
+
return stored ? personIdFromAccessToken(stored.accessToken).personId : null;
|
|
433
|
+
},
|
|
434
|
+
async signOut() {
|
|
435
|
+
const access = await lock(clearLocal);
|
|
436
|
+
emit({ type: "signed_out", reason: "user" });
|
|
437
|
+
if (!access) return { revoked: null };
|
|
438
|
+
try {
|
|
439
|
+
const res = await doFetch(`${base}/auth/v1/logout?scope=local`, {
|
|
440
|
+
method: "POST",
|
|
441
|
+
headers: {
|
|
442
|
+
authorization: `Bearer ${access}`,
|
|
443
|
+
...config.publishableKey ? { apikey: config.publishableKey } : {}
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
return { revoked: res.ok || res.status === 401 || res.status === 403 || res.status === 404 };
|
|
447
|
+
} catch {
|
|
448
|
+
return { revoked: false };
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
onChange(listener) {
|
|
452
|
+
listeners.add(listener);
|
|
453
|
+
return () => {
|
|
454
|
+
listeners.delete(listener);
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|