@aurabase/auth 0.4.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/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/index.cjs +1175 -0
- package/dist/index.d.cts +397 -0
- package/dist/index.d.ts +397 -0
- package/dist/index.js +1170 -0
- package/package.json +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import { BaseService, isTokenExpired, AuraError, refreshOnce, emitAuraEvent } from '@aurabase/core';
|
|
2
|
+
|
|
3
|
+
// src/AuthService.ts
|
|
4
|
+
|
|
5
|
+
// src/SessionManager.ts
|
|
6
|
+
var SessionManager = class {
|
|
7
|
+
opts;
|
|
8
|
+
_interval = null;
|
|
9
|
+
_channel = null;
|
|
10
|
+
_active = false;
|
|
11
|
+
_visHandler = null;
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.opts = { tickMs: 3e4, ...opts };
|
|
14
|
+
}
|
|
15
|
+
get isActive() {
|
|
16
|
+
return this._active;
|
|
17
|
+
}
|
|
18
|
+
start() {
|
|
19
|
+
if (!this.opts.enabled) return;
|
|
20
|
+
if (typeof BroadcastChannel !== "undefined" && !this._channel) {
|
|
21
|
+
this._channel = new BroadcastChannel(this.opts.channelName);
|
|
22
|
+
this._channel.onmessage = (e) => this.opts.onRemoteMessage(e.data);
|
|
23
|
+
}
|
|
24
|
+
if (typeof document !== "undefined" && !this._visHandler) {
|
|
25
|
+
this._visHandler = () => {
|
|
26
|
+
if (document.hidden) {
|
|
27
|
+
this._stopInterval();
|
|
28
|
+
} else if (this._active) {
|
|
29
|
+
this._startInterval();
|
|
30
|
+
if (this.opts.isRefreshDue()) this.opts.onTick();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
document.addEventListener("visibilitychange", this._visHandler);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
setActive(active) {
|
|
37
|
+
this._active = active;
|
|
38
|
+
if (!this.opts.enabled) return;
|
|
39
|
+
const hidden = typeof document !== "undefined" && document.hidden;
|
|
40
|
+
if (active && !hidden) this._startInterval();
|
|
41
|
+
else this._stopInterval();
|
|
42
|
+
}
|
|
43
|
+
broadcast(data) {
|
|
44
|
+
this._channel?.postMessage(data);
|
|
45
|
+
}
|
|
46
|
+
stop() {
|
|
47
|
+
this._stopInterval();
|
|
48
|
+
this._active = false;
|
|
49
|
+
if (this._channel) {
|
|
50
|
+
try {
|
|
51
|
+
this._channel.close();
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
this._channel = null;
|
|
55
|
+
}
|
|
56
|
+
if (this._visHandler && typeof document !== "undefined") {
|
|
57
|
+
document.removeEventListener("visibilitychange", this._visHandler);
|
|
58
|
+
this._visHandler = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
_startInterval() {
|
|
62
|
+
if (this._interval) return;
|
|
63
|
+
this._interval = setInterval(() => {
|
|
64
|
+
if (this.opts.isRefreshDue()) this.opts.onTick();
|
|
65
|
+
}, this.opts.tickMs);
|
|
66
|
+
this._interval.unref?.();
|
|
67
|
+
}
|
|
68
|
+
_stopInterval() {
|
|
69
|
+
if (this._interval) {
|
|
70
|
+
clearInterval(this._interval);
|
|
71
|
+
this._interval = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/jwks.ts
|
|
77
|
+
var MAX_AGE_CAP_MS = 864e5;
|
|
78
|
+
var FORCED_REFETCH_COOLDOWN_MS = 3e4;
|
|
79
|
+
var NEGATIVE_CACHE_TTL_MS = 1e4;
|
|
80
|
+
var DEFAULT_MAX_AGE_MS = 6e5;
|
|
81
|
+
var clientStates = /* @__PURE__ */ new WeakMap();
|
|
82
|
+
function stateFor(scope) {
|
|
83
|
+
let s = clientStates.get(scope);
|
|
84
|
+
if (!s) {
|
|
85
|
+
s = {
|
|
86
|
+
cache: /* @__PURE__ */ new Map(),
|
|
87
|
+
inflight: /* @__PURE__ */ new Map(),
|
|
88
|
+
lastFailedAt: /* @__PURE__ */ new Map(),
|
|
89
|
+
lastForcedFetchAt: /* @__PURE__ */ new Map()
|
|
90
|
+
};
|
|
91
|
+
clientStates.set(scope, s);
|
|
92
|
+
}
|
|
93
|
+
return s;
|
|
94
|
+
}
|
|
95
|
+
function b64urlToBytes(s) {
|
|
96
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - s.length % 4) % 4);
|
|
97
|
+
const bin = atob(b64);
|
|
98
|
+
const out = new Uint8Array(bin.length);
|
|
99
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
function decodeJwt(jwt) {
|
|
103
|
+
const [h, p, s] = jwt.split(".");
|
|
104
|
+
if (!h || !p || !s) throw new Error("malformed jwt");
|
|
105
|
+
const dec = new TextDecoder();
|
|
106
|
+
return {
|
|
107
|
+
header: JSON.parse(dec.decode(b64urlToBytes(h))),
|
|
108
|
+
payload: JSON.parse(dec.decode(b64urlToBytes(p))),
|
|
109
|
+
signingInput: `${h}.${p}`,
|
|
110
|
+
signature: b64urlToBytes(s)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function parseMaxAge(cc) {
|
|
114
|
+
const m = cc?.match(/max-age=(\d+)/);
|
|
115
|
+
return m ? Number(m[1]) * 1e3 : null;
|
|
116
|
+
}
|
|
117
|
+
function fetchJwksDeduped(state, fetchFn, baseUrl) {
|
|
118
|
+
const existing = state.inflight.get(baseUrl);
|
|
119
|
+
if (existing) return existing;
|
|
120
|
+
const p = (async () => {
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetchFn(`${baseUrl}/.well-known/jwks.json`);
|
|
123
|
+
if (!res.ok) throw new Error("jwks_fetch_failed");
|
|
124
|
+
const body = await res.json();
|
|
125
|
+
const parsed = parseMaxAge(res.headers.get("Cache-Control")) ?? DEFAULT_MAX_AGE_MS;
|
|
126
|
+
const maxAge = Math.min(parsed, MAX_AGE_CAP_MS);
|
|
127
|
+
const keys = body.keys ?? [];
|
|
128
|
+
state.cache.set(baseUrl, { keys, fetchedAt: Date.now(), maxAgeMs: maxAge });
|
|
129
|
+
state.lastFailedAt.delete(baseUrl);
|
|
130
|
+
return keys;
|
|
131
|
+
} catch {
|
|
132
|
+
state.lastFailedAt.set(baseUrl, Date.now());
|
|
133
|
+
throw new Error("jwks_fetch_failed");
|
|
134
|
+
}
|
|
135
|
+
})().finally(() => state.inflight.delete(baseUrl));
|
|
136
|
+
state.inflight.set(baseUrl, p);
|
|
137
|
+
return p;
|
|
138
|
+
}
|
|
139
|
+
async function loadJwks(state, fetchFn, baseUrl) {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const c = state.cache.get(baseUrl);
|
|
142
|
+
if (c && now - c.fetchedAt < c.maxAgeMs) return c.keys;
|
|
143
|
+
const failedAt = state.lastFailedAt.get(baseUrl);
|
|
144
|
+
if (failedAt !== void 0 && now - failedAt < NEGATIVE_CACHE_TTL_MS) {
|
|
145
|
+
throw new Error("jwks_fetch_failed");
|
|
146
|
+
}
|
|
147
|
+
return fetchJwksDeduped(state, fetchFn, baseUrl);
|
|
148
|
+
}
|
|
149
|
+
async function forcedRefetchJwks(state, fetchFn, baseUrl) {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const last = state.lastForcedFetchAt.get(baseUrl);
|
|
152
|
+
if (last !== void 0 && now - last < FORCED_REFETCH_COOLDOWN_MS) return null;
|
|
153
|
+
state.lastForcedFetchAt.set(baseUrl, now);
|
|
154
|
+
const failedAt = state.lastFailedAt.get(baseUrl);
|
|
155
|
+
if (failedAt !== void 0 && now - failedAt < NEGATIVE_CACHE_TTL_MS) {
|
|
156
|
+
throw new Error("jwks_fetch_failed");
|
|
157
|
+
}
|
|
158
|
+
return fetchJwksDeduped(state, fetchFn, baseUrl);
|
|
159
|
+
}
|
|
160
|
+
function algParams(alg) {
|
|
161
|
+
if (alg === "RS256") {
|
|
162
|
+
return {
|
|
163
|
+
importAlg: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
164
|
+
verifyAlg: { name: "RSASSA-PKCS1-v1_5" }
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (alg === "ES256") {
|
|
168
|
+
return {
|
|
169
|
+
importAlg: { name: "ECDSA", namedCurve: "P-256" },
|
|
170
|
+
verifyAlg: { name: "ECDSA", hash: "SHA-256" }
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
async function verifyJwtLocal(scope, fetchFn, baseUrl, jwt, opts = {}) {
|
|
176
|
+
let d;
|
|
177
|
+
try {
|
|
178
|
+
d = decodeJwt(jwt);
|
|
179
|
+
} catch {
|
|
180
|
+
return { ok: false, code: "invalid_jwt" };
|
|
181
|
+
}
|
|
182
|
+
const params = algParams(d.header.alg);
|
|
183
|
+
if (!params) return { ok: false, code: "unsupported_alg" };
|
|
184
|
+
const state = stateFor(scope);
|
|
185
|
+
const matchesAlg = (k) => d.header.alg === "RS256" ? k.kty === "RSA" : k.kty === "EC";
|
|
186
|
+
const candidateKeys = (keys2) => d.header.kid ? keys2.filter((k) => k.kid === d.header.kid && matchesAlg(k)) : keys2.filter((k) => matchesAlg(k));
|
|
187
|
+
let keys;
|
|
188
|
+
try {
|
|
189
|
+
keys = await loadJwks(state, fetchFn, baseUrl);
|
|
190
|
+
} catch {
|
|
191
|
+
try {
|
|
192
|
+
opts.onEvent?.(baseUrl);
|
|
193
|
+
} catch {
|
|
194
|
+
}
|
|
195
|
+
return { ok: false, code: "jwks_fetch_failed" };
|
|
196
|
+
}
|
|
197
|
+
let candidates = candidateKeys(keys);
|
|
198
|
+
if (candidates.length === 0 && d.header.kid) {
|
|
199
|
+
let refetched;
|
|
200
|
+
try {
|
|
201
|
+
refetched = await forcedRefetchJwks(state, fetchFn, baseUrl);
|
|
202
|
+
} catch {
|
|
203
|
+
try {
|
|
204
|
+
opts.onEvent?.(baseUrl);
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
return { ok: false, code: "jwks_fetch_failed" };
|
|
208
|
+
}
|
|
209
|
+
if (refetched) {
|
|
210
|
+
keys = refetched;
|
|
211
|
+
candidates = candidateKeys(keys);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (candidates.length === 0) return { ok: false, code: "no_matching_key" };
|
|
215
|
+
const signingBytes = new TextEncoder().encode(d.signingInput);
|
|
216
|
+
let valid = false;
|
|
217
|
+
for (const jwk of candidates) {
|
|
218
|
+
try {
|
|
219
|
+
const key = await crypto.subtle.importKey("jwk", jwk, params.importAlg, false, ["verify"]);
|
|
220
|
+
if (await crypto.subtle.verify(
|
|
221
|
+
params.verifyAlg,
|
|
222
|
+
key,
|
|
223
|
+
d.signature,
|
|
224
|
+
signingBytes
|
|
225
|
+
)) {
|
|
226
|
+
valid = true;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!valid) return { ok: false, code: "signature_invalid" };
|
|
233
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
234
|
+
if (!opts.allowExpired && typeof d.payload.exp === "number" && d.payload.exp < now) {
|
|
235
|
+
return { ok: false, code: "token_expired" };
|
|
236
|
+
}
|
|
237
|
+
if (typeof d.payload.nbf === "number" && d.payload.nbf > now + 60) {
|
|
238
|
+
return { ok: false, code: "token_not_yet_valid" };
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
claims: d.payload,
|
|
243
|
+
header: {
|
|
244
|
+
alg: d.header.alg,
|
|
245
|
+
kid: d.header.kid,
|
|
246
|
+
typ: d.header.typ
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/pkce.ts
|
|
252
|
+
function b64url(bytes) {
|
|
253
|
+
let s = "";
|
|
254
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
255
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
256
|
+
}
|
|
257
|
+
function generateCodeVerifier() {
|
|
258
|
+
const bytes = new Uint8Array(64);
|
|
259
|
+
crypto.getRandomValues(bytes);
|
|
260
|
+
return b64url(bytes);
|
|
261
|
+
}
|
|
262
|
+
async function codeChallengeS256(verifier) {
|
|
263
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
264
|
+
return b64url(new Uint8Array(digest));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/AuthService.ts
|
|
268
|
+
function isMfaChallenge(v) {
|
|
269
|
+
return typeof v === "object" && v !== null && v.mfa_required === true;
|
|
270
|
+
}
|
|
271
|
+
var AuthService = class extends BaseService {
|
|
272
|
+
_stateListeners = /* @__PURE__ */ new Set();
|
|
273
|
+
_sessionManager;
|
|
274
|
+
_applyingRemote = false;
|
|
275
|
+
_unsubStore = null;
|
|
276
|
+
_disposed = false;
|
|
277
|
+
constructor(client, options) {
|
|
278
|
+
super(client);
|
|
279
|
+
const browser = typeof window !== "undefined";
|
|
280
|
+
const enabled = !!options?.forceEnabled || (options?.autoRefresh ?? true) && browser && (options?.persistSession ?? true);
|
|
281
|
+
this._sessionManager = new SessionManager({
|
|
282
|
+
channelName: `aura-${client.projectId}-auth`,
|
|
283
|
+
enabled,
|
|
284
|
+
onTick: () => {
|
|
285
|
+
void this.refreshSession();
|
|
286
|
+
},
|
|
287
|
+
isRefreshDue: () => {
|
|
288
|
+
const t = this.client.authStore.token;
|
|
289
|
+
return !!t && !!this.client.authStore.refreshToken && isTokenExpired(t, 60);
|
|
290
|
+
},
|
|
291
|
+
onRemoteMessage: (data) => this._applyRemoteState(data)
|
|
292
|
+
});
|
|
293
|
+
this._sessionManager.start();
|
|
294
|
+
this._unsubStore = this.client.authStore.onChange((token) => {
|
|
295
|
+
this._sessionManager.setActive(!!token);
|
|
296
|
+
if (!this._applyingRemote) {
|
|
297
|
+
const state = this._broadcastState();
|
|
298
|
+
if (!token || state !== null) {
|
|
299
|
+
this._sessionManager.broadcast(state);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
if (this.client.authStore.token) this._sessionManager.setActive(true);
|
|
304
|
+
const detect = options?.detectSessionInUrl ?? browser;
|
|
305
|
+
if (detect && browser) {
|
|
306
|
+
void this._detectSessionInUrl();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Détecte et consomme une session présente dans l'URL courante au boot (callback
|
|
311
|
+
* OAuth), à l'image de `detectSessionInUrl` de supabase-js :
|
|
312
|
+
* - fragment `#access_token=…&refresh_token=…` (flux implicit) → `setSession`.
|
|
313
|
+
* - query `?token=…&type=recovery` (lien de récupération de mot de passe) → émet
|
|
314
|
+
* `PASSWORD_RECOVERY` avec `recoveryToken` (AUCUNE session posée, PAS de `SIGNED_IN`).
|
|
315
|
+
* - query `?code=…` (flux PKCE) → `exchangeCodeForSession` (relit le verifier).
|
|
316
|
+
* Puis nettoie l'URL via `history.replaceState` (retire tokens/code de la barre
|
|
317
|
+
* d'adresse et de l'historique). Idempotent, no-op si aucun token/code n'est
|
|
318
|
+
* présent, no-op hors navigateur. NE THROW JAMAIS (best-effort au boot).
|
|
319
|
+
*/
|
|
320
|
+
async _detectSessionInUrl() {
|
|
321
|
+
if (typeof window === "undefined") return;
|
|
322
|
+
try {
|
|
323
|
+
const loc = window.location;
|
|
324
|
+
if (!loc) return;
|
|
325
|
+
const hash = typeof loc.hash === "string" ? loc.hash : "";
|
|
326
|
+
const search = typeof loc.search === "string" ? loc.search : "";
|
|
327
|
+
if (hash.includes("access_token")) {
|
|
328
|
+
const initiatedKey = this._oauthInitiatedKey;
|
|
329
|
+
const initiated = this.client.authStore.getItem(initiatedKey);
|
|
330
|
+
if (!initiated) return;
|
|
331
|
+
this.client.authStore.removeItem(initiatedKey);
|
|
332
|
+
const frag = new URLSearchParams(hash.replace(/^#/, ""));
|
|
333
|
+
const accessToken = frag.get("access_token");
|
|
334
|
+
const refreshToken = frag.get("refresh_token");
|
|
335
|
+
if (accessToken && refreshToken) {
|
|
336
|
+
await this.setSession(accessToken, refreshToken);
|
|
337
|
+
this._cleanAuthParamsFromUrl();
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
{
|
|
342
|
+
const qs = new URLSearchParams(search.replace(/^\?/, ""));
|
|
343
|
+
const recoveryToken = qs.get("token");
|
|
344
|
+
if (qs.get("type") === "recovery" && recoveryToken) {
|
|
345
|
+
this._emitEvent("PASSWORD_RECOVERY", null, null, { recoveryToken });
|
|
346
|
+
this._cleanAuthParamsFromUrl();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (/[?&]code=/.test(search)) {
|
|
351
|
+
const qs = new URLSearchParams(search.replace(/^\?/, ""));
|
|
352
|
+
const code = qs.get("code");
|
|
353
|
+
if (code) {
|
|
354
|
+
await this.exchangeCodeForSession(code);
|
|
355
|
+
this._cleanAuthParamsFromUrl();
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Retire de l'URL courante les paramètres d'auth du callback OAuth : le fragment
|
|
364
|
+
* entier (flux implicit) et les query params `code`/`state` (flux PKCE), tout en
|
|
365
|
+
* préservant les autres query params. Via `history.replaceState` (pas de nouvelle
|
|
366
|
+
* entrée d'historique). Best-effort, ne throw jamais.
|
|
367
|
+
*/
|
|
368
|
+
_cleanAuthParamsFromUrl() {
|
|
369
|
+
try {
|
|
370
|
+
if (typeof window === "undefined") return;
|
|
371
|
+
const h = window.history;
|
|
372
|
+
const loc = window.location;
|
|
373
|
+
if (!h || typeof h.replaceState !== "function" || !loc) return;
|
|
374
|
+
const qs = new URLSearchParams(
|
|
375
|
+
typeof loc.search === "string" ? loc.search.replace(/^\?/, "") : ""
|
|
376
|
+
);
|
|
377
|
+
qs.delete("code");
|
|
378
|
+
qs.delete("state");
|
|
379
|
+
qs.delete("token");
|
|
380
|
+
qs.delete("project_id");
|
|
381
|
+
qs.delete("type");
|
|
382
|
+
const remaining = qs.toString();
|
|
383
|
+
const path = loc.pathname ?? "/";
|
|
384
|
+
const clean = path + (remaining ? `?${remaining}` : "");
|
|
385
|
+
h.replaceState(null, "", clean || "/");
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
_broadcastState() {
|
|
390
|
+
const s = this.client.authStore;
|
|
391
|
+
if (!s.token || !s.refreshToken || !s.user || typeof s.user !== "object" || !("id" in s.user) || !s.user.id) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return { token: s.token, refreshToken: s.refreshToken, user: s.user };
|
|
395
|
+
}
|
|
396
|
+
_applyRemoteState(data) {
|
|
397
|
+
if (data !== null) {
|
|
398
|
+
const d = data;
|
|
399
|
+
if (typeof d.token !== "string" || typeof d.refreshToken !== "string" || !d.user || typeof d.user !== "object") {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
this._applyingRemote = true;
|
|
404
|
+
try {
|
|
405
|
+
const store = this.client.authStore;
|
|
406
|
+
const prevToken = store.token;
|
|
407
|
+
if (!data) {
|
|
408
|
+
store.clear();
|
|
409
|
+
this._emitEvent("SIGNED_OUT", null, null);
|
|
410
|
+
} else {
|
|
411
|
+
const s = data;
|
|
412
|
+
store.save(s.token, s.refreshToken, s.user);
|
|
413
|
+
const event = !prevToken ? "SIGNED_IN" : s.token !== prevToken ? "TOKEN_REFRESHED" : "USER_UPDATED";
|
|
414
|
+
this._emitEvent(event, s.user, s.token);
|
|
415
|
+
}
|
|
416
|
+
} finally {
|
|
417
|
+
this._applyingRemote = false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Démonte le cycle de vie proactif : ticker, visibilité, BroadcastChannel, listeners. */
|
|
421
|
+
dispose() {
|
|
422
|
+
if (this._disposed) return;
|
|
423
|
+
this._disposed = true;
|
|
424
|
+
this._sessionManager.stop();
|
|
425
|
+
this._unsubStore?.();
|
|
426
|
+
this._unsubStore = null;
|
|
427
|
+
this._stateListeners.clear();
|
|
428
|
+
}
|
|
429
|
+
get prefix() {
|
|
430
|
+
return `/v1/auth/${this.client.projectId}`;
|
|
431
|
+
}
|
|
432
|
+
/** Clé project-scoped du `code_verifier` PKCE (anti-collision multi-projets). */
|
|
433
|
+
get _pkceVerifierKey() {
|
|
434
|
+
return `aura-${this.client.projectId}-pkce-code-verifier`;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Clé project-scoped du MARQUEUR d'initiation du flux OAuth implicit (SEC-F3).
|
|
438
|
+
* Posé par `signInWithOAuth` (flux implicit) avant le redirect ; vérifié + purgé
|
|
439
|
+
* par `_detectSessionInUrl` au retour. Un fragment `#access_token` forgé/injecté
|
|
440
|
+
* par un attaquant (login-CSRF / session fixation) n'aura PAS ce marqueur → il ne
|
|
441
|
+
* sera jamais adopté.
|
|
442
|
+
*/
|
|
443
|
+
get _oauthInitiatedKey() {
|
|
444
|
+
return `aura-${this.client.projectId}-oauth-implicit-initiated`;
|
|
445
|
+
}
|
|
446
|
+
// -- Auth state change ------------------------------------------------------
|
|
447
|
+
onAuthStateChange(callback) {
|
|
448
|
+
if (this._disposed) return { unsubscribe: () => {
|
|
449
|
+
} };
|
|
450
|
+
this._stateListeners.add(callback);
|
|
451
|
+
const store = this.client.authStore;
|
|
452
|
+
queueMicrotask(() => {
|
|
453
|
+
if (this._stateListeners.has(callback)) {
|
|
454
|
+
callback({ event: "INITIAL_SESSION", user: store.user, accessToken: store.token });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
return {
|
|
458
|
+
unsubscribe: () => {
|
|
459
|
+
this._stateListeners.delete(callback);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
_emitEvent(event, user, accessToken, extra) {
|
|
464
|
+
for (const cb of this._stateListeners) {
|
|
465
|
+
try {
|
|
466
|
+
cb({ event, user, accessToken, ...extra });
|
|
467
|
+
} catch {
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// -- Sign up / sign in / sign out -------------------------------------------
|
|
472
|
+
async signUp(req) {
|
|
473
|
+
const res = await this.request("POST", `${this.prefix}/register`, req);
|
|
474
|
+
if (res.data && !isMfaChallenge(res.data)) {
|
|
475
|
+
const tp = res.data;
|
|
476
|
+
this.client.authStore.save(tp.access_token, tp.refresh_token, tp.user);
|
|
477
|
+
this._emitEvent("SIGNED_IN", tp.user, tp.access_token);
|
|
478
|
+
}
|
|
479
|
+
if (res.data && isMfaChallenge(res.data)) {
|
|
480
|
+
const mfa = res.data;
|
|
481
|
+
this._emitEvent("MFA_CHALLENGE", null, null, {
|
|
482
|
+
mfaToken: mfa.mfa_token,
|
|
483
|
+
mfaSetupRequired: mfa.mfa_setup_required
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return res;
|
|
487
|
+
}
|
|
488
|
+
async signInWithPassword(req) {
|
|
489
|
+
const res = await this.request("POST", `${this.prefix}/login`, req);
|
|
490
|
+
if (res.data && !isMfaChallenge(res.data)) {
|
|
491
|
+
const tp = res.data;
|
|
492
|
+
this.client.authStore.save(tp.access_token, tp.refresh_token, tp.user);
|
|
493
|
+
this._emitEvent("SIGNED_IN", tp.user, tp.access_token);
|
|
494
|
+
}
|
|
495
|
+
if (res.data && isMfaChallenge(res.data)) {
|
|
496
|
+
const mfa = res.data;
|
|
497
|
+
this._emitEvent("MFA_CHALLENGE", null, null, {
|
|
498
|
+
mfaToken: mfa.mfa_token,
|
|
499
|
+
mfaSetupRequired: mfa.mfa_setup_required
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return res;
|
|
503
|
+
}
|
|
504
|
+
async signInWithOAuth(provider, options) {
|
|
505
|
+
const params = new URLSearchParams();
|
|
506
|
+
params.set("project_id", this.client.projectId);
|
|
507
|
+
if (options?.redirectTo) params.set("redirect_uri", options.redirectTo);
|
|
508
|
+
if (options?.scopes) params.set("scopes", options.scopes);
|
|
509
|
+
if (options?.queryParams) {
|
|
510
|
+
for (const [k, v] of Object.entries(options.queryParams)) {
|
|
511
|
+
params.set(k, v);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (options?.flowType === "pkce") {
|
|
515
|
+
if (typeof window !== "undefined" && !this.client.authStore.persistent) {
|
|
516
|
+
console.warn(
|
|
517
|
+
`[aurabase] PKCE (flowType:"pkce") requiert un store persistant (LocalAuthStore) pour que le code_verifier survive au redirect OAuth ; le store courant est en m\xE9moire \u2014 l'\xE9change \xE9chouera au retour.`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
const verifier = generateCodeVerifier();
|
|
522
|
+
const challenge = await codeChallengeS256(verifier);
|
|
523
|
+
this.client.authStore.setItem(this._pkceVerifierKey, verifier);
|
|
524
|
+
params.set("code_challenge", challenge);
|
|
525
|
+
params.set("code_challenge_method", "S256");
|
|
526
|
+
} catch {
|
|
527
|
+
return {
|
|
528
|
+
data: null,
|
|
529
|
+
error: new AuraError(0, {
|
|
530
|
+
error: {
|
|
531
|
+
code: "pkce_generation_failed",
|
|
532
|
+
message: "PKCE: g\xE9n\xE9ration du code_verifier/challenge impossible (WebCrypto indisponible)"
|
|
533
|
+
}
|
|
534
|
+
})
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
if (typeof window !== "undefined" && !this.client.authStore.persistent) {
|
|
539
|
+
console.warn(
|
|
540
|
+
'[aurabase] S\xC9CURIT\xC9 : signInWithOAuth en flux implicit avec un store NON persistant (m\xE9moire) \u2014 le marqueur anti session-fixation ne survivra pas au redirect OAuth et la session ne sera pas adopt\xE9e au retour. Utilisez un LocalAuthStore, et pr\xE9f\xE9rez flowType:"pkce" (recommand\xE9, immunis\xE9 contre la session fixation).'
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
this.client.authStore.setItem(this._oauthInitiatedKey, "1");
|
|
545
|
+
} catch {
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const url = `${this.client.baseUrl}/v1/auth/oauth/${encodeURIComponent(provider)}/start?${params.toString()}`;
|
|
549
|
+
if (typeof window !== "undefined") {
|
|
550
|
+
window.location.href = url;
|
|
551
|
+
}
|
|
552
|
+
return { data: { url }, error: null };
|
|
553
|
+
}
|
|
554
|
+
async signInWithMagicLink(req) {
|
|
555
|
+
return this.request("POST", `${this.prefix}/magic-link`, req);
|
|
556
|
+
}
|
|
557
|
+
async signInAnonymously() {
|
|
558
|
+
const res = await this.request("POST", `${this.prefix}/anonymous`);
|
|
559
|
+
if (res.data) {
|
|
560
|
+
this.client.authStore.save(res.data.access_token, res.data.refresh_token, res.data.user);
|
|
561
|
+
this._emitEvent("SIGNED_IN", res.data.user, res.data.access_token);
|
|
562
|
+
}
|
|
563
|
+
return res;
|
|
564
|
+
}
|
|
565
|
+
async signOut() {
|
|
566
|
+
const refreshToken = this.client.authStore.refreshToken;
|
|
567
|
+
if (!refreshToken) {
|
|
568
|
+
this.client.authStore.clear();
|
|
569
|
+
this._emitEvent("SIGNED_OUT", null, null);
|
|
570
|
+
return { data: null, error: null };
|
|
571
|
+
}
|
|
572
|
+
const res = await this.request("POST", `${this.prefix}/logout`, {
|
|
573
|
+
refresh_token: refreshToken
|
|
574
|
+
}, { skipAuthRefresh: true });
|
|
575
|
+
this.client.authStore.clear();
|
|
576
|
+
this._emitEvent("SIGNED_OUT", null, null);
|
|
577
|
+
return res;
|
|
578
|
+
}
|
|
579
|
+
// -- Session management -----------------------------------------------------
|
|
580
|
+
async refreshSession() {
|
|
581
|
+
const res = await refreshOnce(this.client);
|
|
582
|
+
if (res.data) {
|
|
583
|
+
this._emitEvent("TOKEN_REFRESHED", res.data.user, res.data.access_token);
|
|
584
|
+
}
|
|
585
|
+
return res;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Session courante lue depuis le store (synchrone en pratique, pas d'appel réseau).
|
|
589
|
+
*
|
|
590
|
+
* ÉCART DE FORME AVEC SUPABASE (LOT 3, 3.5, documenté — pas de breaking change) :
|
|
591
|
+
* `supabase-js` renvoie `AuraResponse`-like `{data:{session: Session|null}, error}`
|
|
592
|
+
* où `Session` regroupe `access_token`/`refresh_token`/`expires_at`/`user`. Ici,
|
|
593
|
+
* `getSession()` renvoie directement `{accessToken, user}` (pas d'enveloppe
|
|
594
|
+
* `{data, error}`, pas de `refreshToken`/`expiresAt`). Ce choix est délibéré :
|
|
595
|
+
* cette méthode ne fait aucune requête réseau et ne peut donc jamais échouer
|
|
596
|
+
* (`error` serait toujours `null`) — l'envelopper aurait été un contrat menteur.
|
|
597
|
+
* Le `refreshToken` n'est pas exposé pour éviter qu'un consommateur le manipule
|
|
598
|
+
* hors du store (contrairement à `accessToken`, souvent nécessaire pour des appels
|
|
599
|
+
* `fetch` manuels côté app). Pour la session complète, lire `client.authStore`
|
|
600
|
+
* directement (`.token`, `.refreshToken`, `.user`, `.isValid`).
|
|
601
|
+
*/
|
|
602
|
+
async getSession() {
|
|
603
|
+
return {
|
|
604
|
+
accessToken: this.client.authStore.token,
|
|
605
|
+
user: this.client.authStore.user
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
async setSession(accessToken, refreshToken) {
|
|
609
|
+
const store = this.client.authStore;
|
|
610
|
+
const prev = { token: store.token, refreshToken: store.refreshToken, user: store.user };
|
|
611
|
+
const hadValidPrev = !!(prev.token && prev.refreshToken && prev.user && prev.user.id);
|
|
612
|
+
const restore = () => {
|
|
613
|
+
if (hadValidPrev) {
|
|
614
|
+
store.save(prev.token, prev.refreshToken, prev.user);
|
|
615
|
+
} else {
|
|
616
|
+
store.clear();
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
if (isTokenExpired(accessToken, 0)) {
|
|
620
|
+
store.save(accessToken, refreshToken, {});
|
|
621
|
+
const refreshed = await refreshOnce(this.client);
|
|
622
|
+
if (refreshed.data) {
|
|
623
|
+
this._emitEvent("SIGNED_IN", refreshed.data.user, refreshed.data.access_token);
|
|
624
|
+
return { data: refreshed.data.user, error: null };
|
|
625
|
+
}
|
|
626
|
+
restore();
|
|
627
|
+
return { data: null, error: refreshed.error };
|
|
628
|
+
}
|
|
629
|
+
store.save(accessToken, refreshToken, {});
|
|
630
|
+
const res = await this.request("GET", `${this.prefix}/user`);
|
|
631
|
+
if (res.data) {
|
|
632
|
+
store.save(accessToken, refreshToken, res.data);
|
|
633
|
+
this._emitEvent("SIGNED_IN", res.data, accessToken);
|
|
634
|
+
return res;
|
|
635
|
+
}
|
|
636
|
+
restore();
|
|
637
|
+
return res;
|
|
638
|
+
}
|
|
639
|
+
async exchangeCodeForSession(authCode, codeVerifier) {
|
|
640
|
+
return this._exchangeCode(authCode, codeVerifier, "SIGNED_IN");
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Cœur de l'échange PKCE. `successEvent` détermine l'événement émis en cas de
|
|
644
|
+
* succès : `SIGNED_IN` (connexion normale) ou `PASSWORD_RECOVERY` (callback de
|
|
645
|
+
* récupération de mot de passe, `?code=…&type=recovery` — voir `_detectSessionInUrl`).
|
|
646
|
+
*/
|
|
647
|
+
async _exchangeCode(authCode, codeVerifier, successEvent) {
|
|
648
|
+
const verifierKey = this._pkceVerifierKey;
|
|
649
|
+
const verifier = codeVerifier ?? this.client.authStore.getItem(verifierKey);
|
|
650
|
+
if (!verifier) {
|
|
651
|
+
return {
|
|
652
|
+
data: null,
|
|
653
|
+
error: new AuraError(0, {
|
|
654
|
+
error: { code: "no_code_verifier", message: "No PKCE code_verifier available (not provided and none stored)" }
|
|
655
|
+
})
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const res = await this.request("POST", `${this.prefix}/oauth/pkce`, {
|
|
660
|
+
auth_code: authCode,
|
|
661
|
+
code_verifier: verifier
|
|
662
|
+
});
|
|
663
|
+
if (res.data) {
|
|
664
|
+
this.client.authStore.save(res.data.access_token, res.data.refresh_token, res.data.user);
|
|
665
|
+
this._emitEvent(successEvent, res.data.user, res.data.access_token);
|
|
666
|
+
}
|
|
667
|
+
return res;
|
|
668
|
+
} finally {
|
|
669
|
+
this.client.authStore.removeItem(verifierKey);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async listSessions() {
|
|
673
|
+
const store = this.client.authStore;
|
|
674
|
+
if (store.token && store.refreshToken && !store.isValid) {
|
|
675
|
+
const refreshed = await this.refreshSession();
|
|
676
|
+
if (refreshed.error) {
|
|
677
|
+
return { data: null, error: refreshed.error };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const refreshToken = store.refreshToken;
|
|
681
|
+
return this.request("GET", `${this.prefix}/sessions`, void 0, {
|
|
682
|
+
headers: refreshToken ? { "x-refresh-token": refreshToken } : void 0
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
async revokeSession(sessionId) {
|
|
686
|
+
return this.request("DELETE", `${this.prefix}/sessions/${sessionId}`);
|
|
687
|
+
}
|
|
688
|
+
async revokeAllSessions() {
|
|
689
|
+
const rt = this.client.authStore.refreshToken;
|
|
690
|
+
if (!rt) {
|
|
691
|
+
return {
|
|
692
|
+
data: null,
|
|
693
|
+
error: new AuraError(0, { error: { code: "no_session", message: "No refresh token available" } })
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const res = await this.request(
|
|
697
|
+
"POST",
|
|
698
|
+
`${this.prefix}/logout`,
|
|
699
|
+
{ refresh_token: rt, scope: "global" },
|
|
700
|
+
{ skipAuthRefresh: true }
|
|
701
|
+
);
|
|
702
|
+
if (!res.error) {
|
|
703
|
+
this.client.authStore.clear();
|
|
704
|
+
this._emitEvent("SIGNED_OUT", null, null);
|
|
705
|
+
}
|
|
706
|
+
return res;
|
|
707
|
+
}
|
|
708
|
+
// -- User management --------------------------------------------------------
|
|
709
|
+
/**
|
|
710
|
+
* Récupère l'utilisateur validé côté serveur.
|
|
711
|
+
*
|
|
712
|
+
* Sans argument : valide le token de la SESSION courante (`store.token`),
|
|
713
|
+
* avec refresh proactif — comportement historique.
|
|
714
|
+
*
|
|
715
|
+
* Avec `jwt` (LOT D) : valide le token ARBITRAIRE passé, via l'override
|
|
716
|
+
* `authToken` (`Authorization: Bearer <jwt>`), SANS toucher la session
|
|
717
|
+
* (`skipAuthRefresh`, aucun refresh ni retry-401 sur le store). Le backend
|
|
718
|
+
* applique l'isolation projet native : un token d'un AUTRE projet → 403.
|
|
719
|
+
*/
|
|
720
|
+
async getUser(jwt) {
|
|
721
|
+
if (jwt !== void 0) {
|
|
722
|
+
return this.request("GET", `${this.prefix}/user`, void 0, {
|
|
723
|
+
authToken: jwt,
|
|
724
|
+
skipAuthRefresh: true
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return this.request("GET", `${this.prefix}/user`);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Vérifie et décode les claims d'un JWT.
|
|
731
|
+
*
|
|
732
|
+
* Chemin nominal : vérification LOCALE via JWKS + WebCrypto, uniquement pour
|
|
733
|
+
* `alg ∈ {RS256, ES256}` (anti-alg-confusion : jamais de vérif locale HS256).
|
|
734
|
+
* Chemin fallback : `getUser()` (validation serveur) quand la vérif locale
|
|
735
|
+
* n'est pas applicable — `alg` non supporté (HS256), JWKS vide, aucune clé
|
|
736
|
+
* correspondante, ou fetch JWKS en échec. C'est le chemin nominal sur les
|
|
737
|
+
* déploiements HS256 (JWKS `{"keys":[]}`).
|
|
738
|
+
*
|
|
739
|
+
* FOREIGN-TOKEN (LOT D) : le fallback getUser valide le token RÉELLEMENT visé.
|
|
740
|
+
* Pour le token de session (no-arg, ou `jwt === authStore.token`), c'est
|
|
741
|
+
* `getUser()` (validation du token du store). Pour un `jwt` ÉTRANGER, c'est
|
|
742
|
+
* `getUser(jwt)` qui présente CE token en `Authorization` (override `authToken`,
|
|
743
|
+
* jamais celui du store) : le backend le valide et renvoie SON identité. Aucune
|
|
744
|
+
* misattribution possible — on présente exactement le token à vérifier — et
|
|
745
|
+
* l'isolation projet est native côté gateway (token d'un autre projet → 403,
|
|
746
|
+
* remonté en AuraError, jamais fabriqué).
|
|
747
|
+
*
|
|
748
|
+
* NOTE (fallback) : les claims renvoyées par le chemin fallback sont dérivées
|
|
749
|
+
* de `getUser()` (identité validée serveur) et NE contiennent PAS `exp`/`iat`
|
|
750
|
+
* (ni les autres champs temporels du JWT) — un consommateur ne doit pas se
|
|
751
|
+
* fier à `claims.exp` / `claims.iat` après un fallback. Seul le chemin de
|
|
752
|
+
* vérification locale RS256/ES256 renvoie les claims JWT complètes.
|
|
753
|
+
*/
|
|
754
|
+
async getClaims(jwt, options) {
|
|
755
|
+
const token = jwt ?? this.client.authStore.token;
|
|
756
|
+
const usingStoreToken = jwt === void 0 || jwt === this.client.authStore.token;
|
|
757
|
+
if (!token) {
|
|
758
|
+
return {
|
|
759
|
+
data: null,
|
|
760
|
+
error: new AuraError(0, { error: { code: "no_session", message: "No JWT available" } })
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const r = await verifyJwtLocal(this.client, this.client.fetchFn, this.client.baseUrl, token, {
|
|
764
|
+
allowExpired: options?.allowExpired,
|
|
765
|
+
// LOT 3 (I8) : relie le callback générique de jwks.ts au hook d'observabilité
|
|
766
|
+
// du client (onEvent/debug). jwks.ts reste sans dépendance vers @aurabase/core.
|
|
767
|
+
onEvent: (baseUrl) => emitAuraEvent(this.client.onEvent, this.client.debug, { kind: "jwks-fetch-failed", baseUrl })
|
|
768
|
+
});
|
|
769
|
+
const checkIssAud = (c) => {
|
|
770
|
+
if (options?.issuer !== void 0 && c.iss !== options.issuer) {
|
|
771
|
+
return new AuraError(0, {
|
|
772
|
+
error: { code: "invalid_issuer", message: `getClaims: issuer mismatch` }
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
if (options?.audience !== void 0) {
|
|
776
|
+
const aud = c.aud;
|
|
777
|
+
const audList = Array.isArray(aud) ? aud : aud !== void 0 ? [aud] : [];
|
|
778
|
+
if (!audList.includes(options.audience)) {
|
|
779
|
+
return new AuraError(0, {
|
|
780
|
+
error: { code: "invalid_audience", message: `getClaims: audience mismatch` }
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
};
|
|
786
|
+
if (r.ok) {
|
|
787
|
+
const claims = r.claims;
|
|
788
|
+
const issAudErr = checkIssAud(claims);
|
|
789
|
+
if (issAudErr) return { data: null, error: issAudErr };
|
|
790
|
+
const claimProject = claims.project_id;
|
|
791
|
+
if (typeof claimProject === "string" && claimProject !== this.client.projectId) {
|
|
792
|
+
return {
|
|
793
|
+
data: null,
|
|
794
|
+
error: new AuraError(0, {
|
|
795
|
+
error: {
|
|
796
|
+
code: "project_isolation_violation",
|
|
797
|
+
message: `getClaims: token project mismatch (isolation)`
|
|
798
|
+
}
|
|
799
|
+
})
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return { data: { claims, header: r.header }, error: null };
|
|
803
|
+
}
|
|
804
|
+
if (r.code === "unsupported_alg" || r.code === "no_matching_key" || r.code === "jwks_fetch_failed") {
|
|
805
|
+
let localPayload = {};
|
|
806
|
+
try {
|
|
807
|
+
localPayload = decodeJwt(token).payload;
|
|
808
|
+
} catch {
|
|
809
|
+
}
|
|
810
|
+
const issAudErr = checkIssAud(localPayload);
|
|
811
|
+
if (issAudErr) return { data: null, error: issAudErr };
|
|
812
|
+
if (options?.allowExpired && typeof localPayload.exp === "number" && localPayload.exp < Math.floor(Date.now() / 1e3)) {
|
|
813
|
+
return {
|
|
814
|
+
data: null,
|
|
815
|
+
error: new AuraError(0, {
|
|
816
|
+
error: {
|
|
817
|
+
code: "allow_expired_unsupported_on_fallback",
|
|
818
|
+
message: "getClaims: allowExpired ne peut pas \xEAtre honor\xE9 sur le chemin de validation serveur (fallback HS256/JWKS)"
|
|
819
|
+
}
|
|
820
|
+
})
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
const u = usingStoreToken ? await this.getUser() : await this.getUser(token);
|
|
824
|
+
if (u.data) {
|
|
825
|
+
let realAlg = "unknown";
|
|
826
|
+
try {
|
|
827
|
+
realAlg = decodeJwt(token).header.alg ?? "unknown";
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
data: {
|
|
832
|
+
// Claims dérivées de getUser : PAS de `exp`/`iat` (voir JwtClaims/JSDoc).
|
|
833
|
+
// `exp`/`iat` étant optionnels, plus de `as unknown as` masquant l'absence.
|
|
834
|
+
claims: { ...u.data, sub: u.data.id },
|
|
835
|
+
header: { alg: realAlg }
|
|
836
|
+
},
|
|
837
|
+
error: null
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
return { data: null, error: u.error };
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
data: null,
|
|
844
|
+
error: new AuraError(0, { error: { code: r.code, message: `getClaims: ${r.code}` } })
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
async deleteUser(password) {
|
|
848
|
+
const res = await this.request("DELETE", `${this.prefix}/user`, { password });
|
|
849
|
+
if (!res.error) {
|
|
850
|
+
this.client.authStore.clear();
|
|
851
|
+
this._emitEvent("SIGNED_OUT", null, null);
|
|
852
|
+
}
|
|
853
|
+
return res;
|
|
854
|
+
}
|
|
855
|
+
async updateUser(req) {
|
|
856
|
+
const res = await this.request("PUT", `${this.prefix}/user`, req);
|
|
857
|
+
if (res.data) {
|
|
858
|
+
const store = this.client.authStore;
|
|
859
|
+
if (store.token && store.refreshToken) {
|
|
860
|
+
store.save(store.token, store.refreshToken, res.data);
|
|
861
|
+
}
|
|
862
|
+
this._emitEvent("USER_UPDATED", res.data, this.client.authStore.token);
|
|
863
|
+
}
|
|
864
|
+
return res;
|
|
865
|
+
}
|
|
866
|
+
// -- Password recovery ------------------------------------------------------
|
|
867
|
+
async resetPasswordForEmail(req) {
|
|
868
|
+
return this.request("POST", `${this.prefix}/forgot-password`, req);
|
|
869
|
+
}
|
|
870
|
+
async updatePassword(newPassword, currentPassword) {
|
|
871
|
+
const res = await this.request("PUT", `${this.prefix}/user`, {
|
|
872
|
+
password: newPassword,
|
|
873
|
+
current_password: currentPassword
|
|
874
|
+
});
|
|
875
|
+
if (!res.error) {
|
|
876
|
+
this.client.authStore.clear();
|
|
877
|
+
this._emitEvent("SIGNED_OUT", null, null);
|
|
878
|
+
}
|
|
879
|
+
return res;
|
|
880
|
+
}
|
|
881
|
+
// -- MFA --------------------------------------------------------------------
|
|
882
|
+
async mfaEnroll() {
|
|
883
|
+
return this.request("POST", `${this.prefix}/mfa/setup`);
|
|
884
|
+
}
|
|
885
|
+
async mfaVerifySetup(code) {
|
|
886
|
+
return this.request("POST", `${this.prefix}/mfa/setup/verify`, { code });
|
|
887
|
+
}
|
|
888
|
+
async mfaVerify(req) {
|
|
889
|
+
const res = await this.request("POST", `${this.prefix}/mfa/verify-login`, req);
|
|
890
|
+
if (res.data) {
|
|
891
|
+
this.client.authStore.save(res.data.access_token, res.data.refresh_token, res.data.user);
|
|
892
|
+
this._emitEvent("SIGNED_IN", res.data.user, res.data.access_token);
|
|
893
|
+
}
|
|
894
|
+
return res;
|
|
895
|
+
}
|
|
896
|
+
async mfaUnenroll(password) {
|
|
897
|
+
return this.request("POST", `${this.prefix}/mfa/disable`, { password });
|
|
898
|
+
}
|
|
899
|
+
async verifyOtp(params) {
|
|
900
|
+
switch (params.type) {
|
|
901
|
+
case "signup":
|
|
902
|
+
return this._verifyToTokenPair(`${this.prefix}/verify-email`, { token: params.token });
|
|
903
|
+
case "magiclink":
|
|
904
|
+
return this._verifyToTokenPair(`${this.prefix}/magic-link/verify`, { token: params.token });
|
|
905
|
+
case "email":
|
|
906
|
+
return this._verifyToTokenPair(`${this.prefix}/email-otp/verify`, {
|
|
907
|
+
email: params.email,
|
|
908
|
+
code: params.token
|
|
909
|
+
});
|
|
910
|
+
case "sms":
|
|
911
|
+
return this._verifyToTokenPair(`${this.prefix}/sms/verify`, {
|
|
912
|
+
phone: params.phone,
|
|
913
|
+
code: params.token
|
|
914
|
+
});
|
|
915
|
+
case "recovery": {
|
|
916
|
+
if (!params.password) {
|
|
917
|
+
return {
|
|
918
|
+
data: null,
|
|
919
|
+
error: new AuraError(0, {
|
|
920
|
+
error: {
|
|
921
|
+
code: "missing_password",
|
|
922
|
+
message: "verifyOtp({ type: 'recovery' }) exige `password` : le backend fusionne consommation du token et changement de mot de passe."
|
|
923
|
+
}
|
|
924
|
+
})
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
return this._verifyToTokenPair(`${this.prefix}/reset-password`, {
|
|
928
|
+
token: params.token,
|
|
929
|
+
password: params.password
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
case "email_change": {
|
|
933
|
+
const res = await this.request("POST", `${this.prefix}/user/confirm-email-change`, {
|
|
934
|
+
token: params.token
|
|
935
|
+
});
|
|
936
|
+
if (res.data) {
|
|
937
|
+
const store = this.client.authStore;
|
|
938
|
+
if (store.token && store.refreshToken) {
|
|
939
|
+
store.save(store.token, store.refreshToken, res.data);
|
|
940
|
+
}
|
|
941
|
+
this._emitEvent("USER_UPDATED", res.data, this.client.authStore.token);
|
|
942
|
+
}
|
|
943
|
+
return res;
|
|
944
|
+
}
|
|
945
|
+
case "invite":
|
|
946
|
+
case "phone_change":
|
|
947
|
+
return {
|
|
948
|
+
data: null,
|
|
949
|
+
error: new AuraError(0, {
|
|
950
|
+
error: {
|
|
951
|
+
code: "otp_type_unsupported",
|
|
952
|
+
message: `verifyOtp: le type '${params.type}' n'est pas support\xE9 (aucun endpoint backend).`
|
|
953
|
+
}
|
|
954
|
+
})
|
|
955
|
+
};
|
|
956
|
+
default:
|
|
957
|
+
return {
|
|
958
|
+
data: null,
|
|
959
|
+
error: new AuraError(0, {
|
|
960
|
+
error: { code: "otp_type_unsupported", message: "verifyOtp: type OTP inconnu." }
|
|
961
|
+
})
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Helper commun aux vérifications OTP renvoyant une `TokenPair` : appelle
|
|
967
|
+
* l'endpoint, puis (succès) sauvegarde la session et émet `SIGNED_IN`.
|
|
968
|
+
*/
|
|
969
|
+
async _verifyToTokenPair(path, body) {
|
|
970
|
+
const res = await this.request("POST", path, body);
|
|
971
|
+
if (res.data) {
|
|
972
|
+
this.client.authStore.save(res.data.access_token, res.data.refresh_token, res.data.user);
|
|
973
|
+
this._emitEvent("SIGNED_IN", res.data.user, res.data.access_token);
|
|
974
|
+
}
|
|
975
|
+
return res;
|
|
976
|
+
}
|
|
977
|
+
// -- Email verification -----------------------------------------------------
|
|
978
|
+
/** Rétrocompat : délègue à `verifyOtp({ type: 'signup' })` (POST /verify-email). */
|
|
979
|
+
async verifyEmail(token) {
|
|
980
|
+
return this.verifyOtp({ type: "signup", token });
|
|
981
|
+
}
|
|
982
|
+
async resendVerificationEmail(email) {
|
|
983
|
+
const e = email ?? this.client.authStore.user?.email;
|
|
984
|
+
if (!e) {
|
|
985
|
+
return {
|
|
986
|
+
data: null,
|
|
987
|
+
error: new AuraError(0, { error: { code: "missing_email", message: "Email is required" } })
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
return this.request("POST", `${this.prefix}/resend-verification`, { email: e });
|
|
991
|
+
}
|
|
992
|
+
// -- Email OTP (Phase 2) --------------------------------------------------
|
|
993
|
+
async signInWithEmailOtp(email) {
|
|
994
|
+
return this.request("POST", `${this.prefix}/email-otp/send`, { email });
|
|
995
|
+
}
|
|
996
|
+
/** Rétrocompat : délègue à `verifyOtp({ type: 'email' })` (POST /email-otp/verify). */
|
|
997
|
+
async verifyEmailOtp(email, code) {
|
|
998
|
+
return this.verifyOtp({ type: "email", email, token: code });
|
|
999
|
+
}
|
|
1000
|
+
// -- SMS OTP (Phase 2) ----------------------------------------------------
|
|
1001
|
+
async signInWithSmsOtp(phone) {
|
|
1002
|
+
return this.request("POST", `${this.prefix}/sms/send`, { phone });
|
|
1003
|
+
}
|
|
1004
|
+
/** Rétrocompat : délègue à `verifyOtp({ type: 'sms' })` (POST /sms/verify). */
|
|
1005
|
+
async verifySmsOtp(phone, code) {
|
|
1006
|
+
return this.verifyOtp({ type: "sms", phone, token: code });
|
|
1007
|
+
}
|
|
1008
|
+
// -- Identity linking (Phase 2) -------------------------------------------
|
|
1009
|
+
async listIdentities() {
|
|
1010
|
+
return this.request("GET", `${this.prefix}/user/identities`);
|
|
1011
|
+
}
|
|
1012
|
+
async linkIdentity(provider) {
|
|
1013
|
+
return this.request("POST", `${this.prefix}/user/identities/link/${provider}`);
|
|
1014
|
+
}
|
|
1015
|
+
async unlinkIdentity(identityId) {
|
|
1016
|
+
return this.request("DELETE", `${this.prefix}/user/identities/${identityId}`);
|
|
1017
|
+
}
|
|
1018
|
+
// -- Email change (Phase 2) -----------------------------------------------
|
|
1019
|
+
/**
|
|
1020
|
+
* Rétrocompat : délègue à `verifyOtp({ type: 'email_change' })`.
|
|
1021
|
+
* CHANGEMENT ASSUMÉ (parité) : émet désormais `USER_UPDATED` (auparavant aucun
|
|
1022
|
+
* événement) — voir CHANGELOG.
|
|
1023
|
+
*/
|
|
1024
|
+
async confirmEmailChange(token) {
|
|
1025
|
+
return this.verifyOtp({ type: "email_change", token });
|
|
1026
|
+
}
|
|
1027
|
+
// -- MFA recovery codes (Phase 2) -----------------------------------------
|
|
1028
|
+
async regenerateRecoveryCodes(code) {
|
|
1029
|
+
return this.request("POST", `${this.prefix}/mfa/recovery-codes/regenerate`, { code });
|
|
1030
|
+
}
|
|
1031
|
+
// -- Native mobile OAuth (Phase 2) ----------------------------------------
|
|
1032
|
+
async signInWithIdToken(provider, idToken, accessToken) {
|
|
1033
|
+
const res = await this.request("POST", `${this.prefix}/token`, {
|
|
1034
|
+
provider,
|
|
1035
|
+
id_token: idToken,
|
|
1036
|
+
access_token: accessToken
|
|
1037
|
+
});
|
|
1038
|
+
if (res.data) {
|
|
1039
|
+
this.client.authStore.save(res.data.access_token, res.data.refresh_token, res.data.user);
|
|
1040
|
+
this._emitEvent("SIGNED_IN", res.data.user, res.data.access_token);
|
|
1041
|
+
}
|
|
1042
|
+
return res;
|
|
1043
|
+
}
|
|
1044
|
+
// -- Project settings / public config --------------------------------------
|
|
1045
|
+
/** GoTrue parity : GET /settings — config publique du projet (pas d'auth requise). */
|
|
1046
|
+
async getSettings() {
|
|
1047
|
+
return this.request("GET", `${this.prefix}/settings`);
|
|
1048
|
+
}
|
|
1049
|
+
/** Clé publiable (anon key) du projet — GET /anon-key (public, à l'image du modèle Supabase). */
|
|
1050
|
+
async getAnonKey() {
|
|
1051
|
+
return this.request("GET", `${this.prefix}/anon-key`);
|
|
1052
|
+
}
|
|
1053
|
+
// -- Password recovery (token consumption) ----------------------------------
|
|
1054
|
+
/**
|
|
1055
|
+
* Consomme le token de récupération de mot de passe et applique le nouveau mot de passe.
|
|
1056
|
+
* Body confirmé au handler (`ResetPasswordRequest` — services/aura-auth/src/handlers/auth.rs) :
|
|
1057
|
+
* `{ token, password }`. Révoque les anciennes sessions et renvoie une nouvelle TokenPair.
|
|
1058
|
+
*/
|
|
1059
|
+
async resetPassword(token, newPassword) {
|
|
1060
|
+
return this.verifyOtp({ type: "recovery", token, password: newPassword });
|
|
1061
|
+
}
|
|
1062
|
+
// -- Anonymous promotion -----------------------------------------------------
|
|
1063
|
+
/**
|
|
1064
|
+
* Convertit l'utilisateur anonyme courant (JWT requis) en compte régulier email+password.
|
|
1065
|
+
* Body confirmé au handler (`PromoteAnonymousRequest`) : `{ email, password }`.
|
|
1066
|
+
*/
|
|
1067
|
+
async promoteAnonymous(credentials) {
|
|
1068
|
+
const res = await this.request("POST", `${this.prefix}/anonymous/promote`, credentials);
|
|
1069
|
+
if (res.data) {
|
|
1070
|
+
this.client.authStore.save(res.data.access_token, res.data.refresh_token, res.data.user);
|
|
1071
|
+
this._emitEvent("USER_UPDATED", res.data.user, res.data.access_token);
|
|
1072
|
+
}
|
|
1073
|
+
return res;
|
|
1074
|
+
}
|
|
1075
|
+
// -- Magic link verification -------------------------------------------------
|
|
1076
|
+
/**
|
|
1077
|
+
* Consomme le token de magic link et renvoie une TokenPair.
|
|
1078
|
+
* Body confirmé au handler (`VerifyMagicLinkRequest`) : `{ token }`.
|
|
1079
|
+
*/
|
|
1080
|
+
async verifyMagicLink(token) {
|
|
1081
|
+
return this.verifyOtp({ type: "magiclink", token });
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
var AuthAdminService = class extends BaseService {
|
|
1085
|
+
get prefix() {
|
|
1086
|
+
return `/v1/auth/${this.client.projectId}/admin`;
|
|
1087
|
+
}
|
|
1088
|
+
// -- Users ----------------------------------------------------------------
|
|
1089
|
+
async listUsers(params) {
|
|
1090
|
+
const query = new URLSearchParams();
|
|
1091
|
+
if (params?.limit !== void 0) query.set("limit", String(params.limit));
|
|
1092
|
+
if (params?.offset !== void 0) query.set("offset", String(params.offset));
|
|
1093
|
+
if (params?.search) query.set("search", params.search);
|
|
1094
|
+
const qs = query.toString();
|
|
1095
|
+
return this.request("GET", `${this.prefix}/users${qs ? "?" + qs : ""}`);
|
|
1096
|
+
}
|
|
1097
|
+
async getUser(userId) {
|
|
1098
|
+
return this.request("GET", `${this.prefix}/users/${userId}`);
|
|
1099
|
+
}
|
|
1100
|
+
async createUser(data) {
|
|
1101
|
+
return this.request("POST", `${this.prefix}/users`, data);
|
|
1102
|
+
}
|
|
1103
|
+
async updateUser(userId, data) {
|
|
1104
|
+
return this.request("PUT", `${this.prefix}/users/${userId}`, data);
|
|
1105
|
+
}
|
|
1106
|
+
async deleteUser(userId) {
|
|
1107
|
+
return this.request("DELETE", `${this.prefix}/users/${userId}`);
|
|
1108
|
+
}
|
|
1109
|
+
async banUser(userId) {
|
|
1110
|
+
return this.request("POST", `${this.prefix}/users/${userId}/ban`, {});
|
|
1111
|
+
}
|
|
1112
|
+
async unbanUser(userId) {
|
|
1113
|
+
return this.request("POST", `${this.prefix}/users/${userId}/unban`, {});
|
|
1114
|
+
}
|
|
1115
|
+
async generateLink(userId, type) {
|
|
1116
|
+
return this.request("POST", `${this.prefix}/users/${userId}/generate-link`, { type });
|
|
1117
|
+
}
|
|
1118
|
+
async getUserStats() {
|
|
1119
|
+
return this.request("GET", `${this.prefix}/users/stats`);
|
|
1120
|
+
}
|
|
1121
|
+
async listUserIdentities(userId) {
|
|
1122
|
+
return this.request("GET", `${this.prefix}/users/${userId}/identities`);
|
|
1123
|
+
}
|
|
1124
|
+
// -- Sessions ---------------------------------------------------------------
|
|
1125
|
+
async listSessions(params) {
|
|
1126
|
+
const query = new URLSearchParams();
|
|
1127
|
+
if (params?.userId !== void 0) query.set("user_id", params.userId);
|
|
1128
|
+
if (params?.limit !== void 0) query.set("limit", String(params.limit));
|
|
1129
|
+
if (params?.offset !== void 0) query.set("offset", String(params.offset));
|
|
1130
|
+
const qs = query.toString();
|
|
1131
|
+
return this.request("GET", `${this.prefix}/sessions${qs ? "?" + qs : ""}`);
|
|
1132
|
+
}
|
|
1133
|
+
async revokeSession(sessionId) {
|
|
1134
|
+
return this.request("DELETE", `${this.prefix}/sessions/${sessionId}`);
|
|
1135
|
+
}
|
|
1136
|
+
// -- Audit log ------------------------------------------------------------
|
|
1137
|
+
async getAuditLog(params) {
|
|
1138
|
+
const query = new URLSearchParams();
|
|
1139
|
+
if (params?.userId !== void 0) query.set("user_id", params.userId);
|
|
1140
|
+
if (params?.limit !== void 0) query.set("limit", String(params.limit));
|
|
1141
|
+
if (params?.offset !== void 0) query.set("offset", String(params.offset));
|
|
1142
|
+
const qs = query.toString();
|
|
1143
|
+
return this.request("GET", `${this.prefix}/audit-log${qs ? "?" + qs : ""}`);
|
|
1144
|
+
}
|
|
1145
|
+
// -- Settings -------------------------------------------------------------
|
|
1146
|
+
async getSettings() {
|
|
1147
|
+
return this.request("GET", `${this.prefix}/settings`);
|
|
1148
|
+
}
|
|
1149
|
+
async updateSettings(data) {
|
|
1150
|
+
return this.request("PUT", `${this.prefix}/settings`, data);
|
|
1151
|
+
}
|
|
1152
|
+
// -- Email templates ------------------------------------------------------
|
|
1153
|
+
async listEmailTemplates() {
|
|
1154
|
+
return this.request("GET", `${this.prefix}/email-templates`);
|
|
1155
|
+
}
|
|
1156
|
+
async getEmailTemplate(type) {
|
|
1157
|
+
return this.request("GET", `${this.prefix}/email-templates/${type}`);
|
|
1158
|
+
}
|
|
1159
|
+
async updateEmailTemplate(type, data) {
|
|
1160
|
+
return this.request("PUT", `${this.prefix}/email-templates/${type}`, data);
|
|
1161
|
+
}
|
|
1162
|
+
async deleteEmailTemplate(type) {
|
|
1163
|
+
return this.request("DELETE", `${this.prefix}/email-templates/${type}`);
|
|
1164
|
+
}
|
|
1165
|
+
async testEmailTemplate(type, body) {
|
|
1166
|
+
return this.request("POST", `${this.prefix}/email-templates/${type}/test`, body);
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
export { AuthAdminService, AuthService, SessionManager, isMfaChallenge };
|