@ascencia/id-core 0.1.0-rc.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/src/client.ts ADDED
@@ -0,0 +1,657 @@
1
+ /**
2
+ * `AscenciaClient` : le flux `authorization_code` + PKCE, de bout en bout, sans
3
+ * la moindre dépendance ni le moindre appel au DOM.
4
+ *
5
+ * Ce qui est **volontairement absent** : la popup, l'overlay, le `postMessage`
6
+ * et le repli redirection (docs/04-AUTH-FLOWS.md §7). Cette classe fabrique
7
+ * l'URL d'autorisation et consomme le retour ; qui navigue, et comment, est la
8
+ * responsabilité du widget ou du SDK de framework.
9
+ */
10
+
11
+ import { evaluate } from '@ascencia/id-rbac';
12
+ import { DEFAULT_DISCOVERY_TTL_MS, discover, normalizeIssuer } from './discovery';
13
+ import { AscenciaError, toAscenciaError } from './errors';
14
+ import { CODE_CHALLENGE_METHOD, challengeFromVerifier, generateNonce, generateState, generateVerifier } from './pkce';
15
+ import { resolveStorage } from './storage';
16
+ import type {
17
+ AscenciaConfig,
18
+ AscenciaEventHandler,
19
+ AscenciaEventMap,
20
+ AscenciaEventName,
21
+ AuthContext,
22
+ AuthorizeRequest,
23
+ CallbackInput,
24
+ CallbackParams,
25
+ CallbackResult,
26
+ DiscoveryDocument,
27
+ FetchLike,
28
+ SignInOptions,
29
+ SignOutOptions,
30
+ StorageAdapter,
31
+ Tokens,
32
+ Unsubscribe,
33
+ User,
34
+ } from './types';
35
+
36
+ const DEFAULT_SCOPES = ['openid', 'profile', 'email', 'ascencia.roles'];
37
+ const DEFAULT_SKEW_SECONDS = 60;
38
+ const DEFAULT_CONTEXT_TTL_MS = 60_000;
39
+
40
+ /** Même TTL que le `state` côté serveur (docs/04-AUTH-FLOWS.md §7). */
41
+ const TRANSACTION_TTL_MS = 600_000;
42
+
43
+ /** Ce qu'on garde entre l'appel à `/oauth/authorize` et le retour sur le `redirect_uri`. */
44
+ interface Transaction {
45
+ state: string;
46
+ verifier: string;
47
+ nonce: string;
48
+ redirectUri: string;
49
+ createdAt: number;
50
+ }
51
+
52
+ interface TokenEndpointResponse {
53
+ access_token?: unknown;
54
+ token_type?: unknown;
55
+ expires_in?: unknown;
56
+ refresh_token?: unknown;
57
+ id_token?: unknown;
58
+ scope?: unknown;
59
+ error?: unknown;
60
+ error_description?: unknown;
61
+ }
62
+
63
+ function asString(value: unknown): string | null {
64
+ return typeof value === 'string' && value.length > 0 ? value : null;
65
+ }
66
+
67
+ /**
68
+ * Traduit les claims OIDC standard vers la forme exposée aux développeurs.
69
+ * `/oauth/userinfo` parle `name`/`picture`, le reste du produit parle
70
+ * `display_name`/`avatar_url`.
71
+ */
72
+ function userFromClaims(claims: Record<string, unknown>): User {
73
+ return {
74
+ id: asString(claims.sub) ?? '',
75
+ email: asString(claims.email),
76
+ email_verified: claims.email_verified === true,
77
+ display_name: asString(claims.name),
78
+ username: asString(claims.preferred_username),
79
+ avatar_url: asString(claims.picture),
80
+ locale: asString(claims.locale),
81
+ };
82
+ }
83
+
84
+ function parseCallback(input: CallbackInput): CallbackParams {
85
+ if (typeof input === 'string' || input instanceof URL) {
86
+ const url = typeof input === 'string' ? new URL(input, 'http://localhost') : input;
87
+ // `response_mode=fragment` place les paramètres après le `#`, pas après le `?`.
88
+ const fragment = new URLSearchParams(url.hash.replace(/^#/, ''));
89
+ const query = url.searchParams;
90
+ const pick = (key: string) => query.get(key) ?? fragment.get(key);
91
+ return {
92
+ code: pick('code'),
93
+ state: pick('state'),
94
+ error: pick('error'),
95
+ error_description: pick('error_description'),
96
+ };
97
+ }
98
+
99
+ if (input instanceof URLSearchParams) {
100
+ return {
101
+ code: input.get('code'),
102
+ state: input.get('state'),
103
+ error: input.get('error'),
104
+ error_description: input.get('error_description'),
105
+ };
106
+ }
107
+
108
+ return input;
109
+ }
110
+
111
+ export class AscenciaClient {
112
+ readonly issuer: string;
113
+
114
+ readonly #config: AscenciaConfig;
115
+ readonly #fetch: FetchLike;
116
+ readonly #tokenStore: StorageAdapter;
117
+ readonly #transactionStore: StorageAdapter;
118
+ readonly #prefix: string;
119
+ readonly #skewMs: number;
120
+ readonly #listeners = new Map<AscenciaEventName, Set<(...args: never[]) => void>>();
121
+
122
+ #tokens: Tokens | null = null;
123
+ #user: User | null = null;
124
+ #context: AuthContext | null = null;
125
+ #contextFetchedAt = 0;
126
+ #refreshing: Promise<Tokens> | null = null;
127
+
128
+ constructor(config: AscenciaConfig) {
129
+ if (!config.issuer) throw new AscenciaError('invalid_config', '`issuer` est obligatoire.');
130
+ if (!config.clientId) throw new AscenciaError('invalid_config', '`clientId` est obligatoire.');
131
+ if (!config.redirectUri) {
132
+ throw new AscenciaError('invalid_config', '`redirectUri` est obligatoire.');
133
+ }
134
+
135
+ this.#config = config;
136
+ this.issuer = normalizeIssuer(config.issuer);
137
+ this.#prefix = config.storagePrefix ?? 'ascencia';
138
+ this.#skewMs = (config.clockSkewSeconds ?? DEFAULT_SKEW_SECONDS) * 1000;
139
+
140
+ const provided = config.fetch;
141
+ // Sans `bind`, `fetch` détaché du global lève une « Illegal invocation ».
142
+ this.#fetch = provided ?? ((input, init) => globalThis.fetch(input, init));
143
+
144
+ // Les jetons restent en mémoire tant que le développeur n'a pas choisi
145
+ // autre chose ; la transaction PKCE, elle, doit survivre à une navigation
146
+ // complète, sinon le repli redirection est impossible.
147
+ this.#tokenStore = resolveStorage(config.storage, 'memory');
148
+ this.#transactionStore = resolveStorage(config.transactionStorage, 'session');
149
+
150
+ this.#tokens = this.#restoreTokens();
151
+ }
152
+
153
+ // ── Événements ─────────────────────────────────────────────────────────────
154
+
155
+ on<E extends AscenciaEventName>(event: E, handler: AscenciaEventHandler<E>): Unsubscribe {
156
+ let set = this.#listeners.get(event);
157
+ if (!set) {
158
+ set = new Set();
159
+ this.#listeners.set(event, set);
160
+ }
161
+ set.add(handler as (...args: never[]) => void);
162
+ return () => this.off(event, handler);
163
+ }
164
+
165
+ off<E extends AscenciaEventName>(event: E, handler: AscenciaEventHandler<E>): void {
166
+ this.#listeners.get(event)?.delete(handler as (...args: never[]) => void);
167
+ }
168
+
169
+ #emit<E extends AscenciaEventName>(event: E, ...args: AscenciaEventMap[E]): void {
170
+ for (const handler of this.#listeners.get(event) ?? []) {
171
+ try {
172
+ (handler as AscenciaEventHandler<E>)(...args);
173
+ } catch {
174
+ // Un abonné qui plante ne doit ni interrompre les suivants ni faire
175
+ // échouer l'opération qui a déclenché l'événement.
176
+ }
177
+ }
178
+ }
179
+
180
+ // ── Métadonnées ────────────────────────────────────────────────────────────
181
+
182
+ async metadata(): Promise<DiscoveryDocument> {
183
+ if (this.#config.metadata) return this.#config.metadata;
184
+ return discover(this.issuer, {
185
+ fetch: this.#fetch,
186
+ ttlMs: this.#config.discoveryTtlMs ?? DEFAULT_DISCOVERY_TTL_MS,
187
+ });
188
+ }
189
+
190
+ // ── Autorisation ───────────────────────────────────────────────────────────
191
+
192
+ async buildAuthorizeUrl(options: SignInOptions = {}): Promise<AuthorizeRequest> {
193
+ const metadata = await this.metadata();
194
+
195
+ const state = generateState();
196
+ const nonce = generateNonce();
197
+ const verifier = generateVerifier();
198
+ const challenge = await challengeFromVerifier(verifier);
199
+ const redirectUri = options.redirectUri ?? this.#config.redirectUri;
200
+
201
+ const url = new URL(metadata.authorization_endpoint);
202
+ const params = url.searchParams;
203
+ params.set('response_type', 'code');
204
+ params.set('client_id', this.#config.clientId);
205
+ params.set('redirect_uri', redirectUri);
206
+ params.set('scope', (options.scopes ?? this.#config.scopes ?? DEFAULT_SCOPES).join(' '));
207
+ params.set('state', state);
208
+ params.set('nonce', nonce);
209
+ params.set('code_challenge', challenge);
210
+ params.set('code_challenge_method', CODE_CHALLENGE_METHOD);
211
+
212
+ // `screen: 'register'` est du vocabulaire produit ; sur le fil, c'est le
213
+ // `prompt=create` d'OIDC. Un `prompt` explicite reste prioritaire.
214
+ const prompt = options.prompt ?? (options.screen === 'register' ? 'create' : undefined);
215
+ if (prompt) params.set('prompt', prompt);
216
+
217
+ if (options.provider) params.set('provider', options.provider);
218
+ if (options.loginHint) params.set('login_hint', options.loginHint);
219
+ if (options.uiLocales) params.set('ui_locales', options.uiLocales);
220
+ if (options.acrValues) params.set('acr_values', options.acrValues);
221
+ if (options.maxAge !== undefined) params.set('max_age', String(options.maxAge));
222
+ if (options.responseMode) params.set('response_mode', options.responseMode);
223
+ if (this.#config.app) params.set('app', this.#config.app);
224
+
225
+ for (const [key, value] of Object.entries(options.extraParams ?? {})) {
226
+ params.set(key, value);
227
+ }
228
+
229
+ this.#saveTransaction({ state, verifier, nonce, redirectUri, createdAt: Date.now() });
230
+
231
+ return {
232
+ url: url.toString(),
233
+ state,
234
+ nonce,
235
+ codeVerifier: verifier,
236
+ codeChallenge: challenge,
237
+ redirectUri,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Consomme le retour d'autorisation : vérifie le `state`, échange le code,
243
+ * charge le profil. Par défaut, lit `location.href`.
244
+ */
245
+ async handleCallback(input?: CallbackInput): Promise<CallbackResult> {
246
+ const source = input ?? globalThis.location?.href;
247
+ if (source === undefined) {
248
+ throw this.#fail(
249
+ new AscenciaError('invalid_request', "Aucune URL de retour : passez-la explicitement à handleCallback()."),
250
+ );
251
+ }
252
+
253
+ const params = parseCallback(source);
254
+
255
+ if (params.error) {
256
+ throw this.#fail(
257
+ new AscenciaError(params.error, params.error_description ?? params.error, {
258
+ description: params.error_description ?? null,
259
+ }),
260
+ );
261
+ }
262
+
263
+ if (!params.state) {
264
+ throw this.#fail(
265
+ new AscenciaError('invalid_state', 'Le paramètre `state` est absent du retour.'),
266
+ );
267
+ }
268
+
269
+ // Lecture ET suppression avant tout échange : le `state` est à usage
270
+ // unique, un rejeu de la même URL de retour doit échouer même si le
271
+ // premier passage a planté plus loin.
272
+ const transaction = this.#takeTransaction(params.state);
273
+ if (!transaction) {
274
+ throw this.#fail(
275
+ new AscenciaError(
276
+ 'invalid_state',
277
+ "Le `state` reçu ne correspond à aucune demande d'autorisation en cours.",
278
+ ),
279
+ );
280
+ }
281
+
282
+ if (transaction.state !== params.state) {
283
+ throw this.#fail(new AscenciaError('invalid_state', 'Le `state` reçu ne correspond pas.'));
284
+ }
285
+
286
+ if (Date.now() - transaction.createdAt > TRANSACTION_TTL_MS) {
287
+ throw this.#fail(
288
+ new AscenciaError('state_expired', "La demande d'autorisation a expiré (10 min)."),
289
+ );
290
+ }
291
+
292
+ if (!params.code) {
293
+ throw this.#fail(new AscenciaError('invalid_request', 'Le paramètre `code` est absent.'));
294
+ }
295
+
296
+ const tokens = await this.exchangeCode(params.code, transaction.verifier, transaction.redirectUri);
297
+
298
+ const user = (await this.getUser({ force: true })) ?? {
299
+ id: '',
300
+ email: null,
301
+ email_verified: false,
302
+ display_name: null,
303
+ username: null,
304
+ avatar_url: null,
305
+ locale: null,
306
+ };
307
+
308
+ this.#emit('signin', user);
309
+ return { tokens, user };
310
+ }
311
+
312
+ async exchangeCode(code: string, codeVerifier: string, redirectUri?: string): Promise<Tokens> {
313
+ const tokens = await this.#postToken({
314
+ grant_type: 'authorization_code',
315
+ code,
316
+ code_verifier: codeVerifier,
317
+ redirect_uri: redirectUri ?? this.#config.redirectUri,
318
+ });
319
+
320
+ this.#storeTokens(tokens);
321
+ return tokens;
322
+ }
323
+
324
+ // ── Jetons ─────────────────────────────────────────────────────────────────
325
+
326
+ /**
327
+ * Les appels concurrents partagent un unique aller-retour : trois composants
328
+ * qui demandent le jeton au même instant après une expiration ne doivent pas
329
+ * produire trois rotations de refresh token, ce que l'IdP prendrait pour un
330
+ * rejeu et sanctionnerait en révoquant toute la famille.
331
+ */
332
+ async refresh(): Promise<Tokens> {
333
+ if (this.#refreshing) return this.#refreshing;
334
+
335
+ const pending = this.#performRefresh();
336
+ this.#refreshing = pending;
337
+
338
+ const release = () => {
339
+ if (this.#refreshing === pending) this.#refreshing = null;
340
+ };
341
+ pending.then(release, release);
342
+
343
+ return pending;
344
+ }
345
+
346
+ async #performRefresh(): Promise<Tokens> {
347
+ const refreshToken = this.#tokens?.refreshToken ?? this.#tokenStore.get(this.#refreshKey());
348
+ if (!refreshToken) {
349
+ throw new AscenciaError(
350
+ 'no_refresh_token',
351
+ "Aucun refresh token disponible. Les clients publics qui laissent l'IdP gérer le cookie `__Host` doivent repasser par une autorisation.",
352
+ );
353
+ }
354
+
355
+ const tokens = await this.#postToken({
356
+ grant_type: 'refresh_token',
357
+ refresh_token: refreshToken,
358
+ });
359
+
360
+ this.#storeTokens(tokens);
361
+ this.#emit('refresh', tokens);
362
+ return tokens;
363
+ }
364
+
365
+ /** `null` si personne n'est connecté ou si le renouvellement a échoué. */
366
+ async getAccessToken(): Promise<string | null> {
367
+ if (this.#tokens && !this.#isExpired(this.#tokens)) return this.#tokens.accessToken;
368
+
369
+ const hasRefresh = Boolean(this.#tokens?.refreshToken ?? this.#tokenStore.get(this.#refreshKey()));
370
+ if (!hasRefresh) return null;
371
+
372
+ try {
373
+ return (await this.refresh()).accessToken;
374
+ } catch (cause) {
375
+ // Un refresh refusé signifie que la session est morte côté serveur :
376
+ // on nettoie plutôt que de laisser l'application boucler sur un 401.
377
+ this.#emit('error', toAscenciaError(cause, 'refresh_failed'));
378
+ this.#clearSession();
379
+ this.#emit('signout');
380
+ return null;
381
+ }
382
+ }
383
+
384
+ getTokens(): Tokens | null {
385
+ return this.#tokens;
386
+ }
387
+
388
+ isAuthenticated(): boolean {
389
+ if (!this.#tokens) return false;
390
+ return !this.#isExpired(this.#tokens) || Boolean(this.#tokens.refreshToken);
391
+ }
392
+
393
+ // ── Profil et droits ───────────────────────────────────────────────────────
394
+
395
+ async getUser(options: { force?: boolean } = {}): Promise<User | null> {
396
+ if (this.#user && !options.force) return this.#user;
397
+
398
+ const metadata = await this.metadata();
399
+ const endpoint = metadata.userinfo_endpoint ?? `${this.issuer}/oauth/userinfo`;
400
+
401
+ const claims = await this.#authorizedJson<Record<string, unknown>>(endpoint);
402
+ if (!claims) return null;
403
+
404
+ this.#user = userFromClaims(claims);
405
+ return this.#user;
406
+ }
407
+
408
+ /**
409
+ * `GET /api/v1/me/context` : profil, rôles et permissions en une requête.
410
+ * Le cache court évite qu'un rendu qui appelle `can()` dix fois déclenche dix
411
+ * allers-retours ; l'invalidation immédiate passe par le webhook
412
+ * `roles.changed` côté application (docs/05-API.md §3).
413
+ */
414
+ async getContext(options: { force?: boolean } = {}): Promise<AuthContext | null> {
415
+ const ttl = this.#config.contextTtlMs ?? DEFAULT_CONTEXT_TTL_MS;
416
+ if (this.#context && !options.force && Date.now() - this.#contextFetchedAt < ttl) {
417
+ return this.#context;
418
+ }
419
+
420
+ const url = new URL(`${this.issuer}/api/v1/me/context`);
421
+ if (this.#config.app) url.searchParams.set('app', this.#config.app);
422
+
423
+ const context = await this.#authorizedJson<AuthContext>(url.toString());
424
+ if (!context) return null;
425
+
426
+ this.#context = context;
427
+ this.#contextFetchedAt = Date.now();
428
+ if (context.account) this.#user = context.account;
429
+ return context;
430
+ }
431
+
432
+ /**
433
+ * Synchrone et purement locale : elle répond depuis le dernier contexte
434
+ * chargé. Une vérification qui engage quelque chose se fait côté serveur.
435
+ */
436
+ can(permission: string): boolean {
437
+ const context = this.#context;
438
+ if (!context) return false;
439
+ return evaluate({ allow: context.permissions, deny: context.denied }, permission);
440
+ }
441
+
442
+ hasRole(key: string): boolean {
443
+ const context = this.#context;
444
+ if (!context) return false;
445
+ return context.roles.some((role) => role.key === key) || context.platform_roles.includes(key);
446
+ }
447
+
448
+ getCachedContext(): AuthContext | null {
449
+ return this.#context;
450
+ }
451
+
452
+ // ── Déconnexion ────────────────────────────────────────────────────────────
453
+
454
+ /**
455
+ * Renvoie l'URL de fin de session quand `global` est demandé, `null` sinon.
456
+ *
457
+ * Le refresh token n'est pas révoqué au passage : `/oauth/revoke` exige une
458
+ * authentification de client, dont un client public ne dispose pas. La
459
+ * déconnexion globale, elle, coupe la session côté IdP.
460
+ */
461
+ async signOut(options: SignOutOptions = {}): Promise<string | null> {
462
+ const idToken = this.#tokens?.idToken ?? null;
463
+
464
+ this.#clearSession();
465
+ this.#emit('signout');
466
+
467
+ if (!options.global) return null;
468
+
469
+ const metadata = await this.metadata();
470
+ const endpoint = metadata.end_session_endpoint ?? `${this.issuer}/oauth/logout`;
471
+ const url = new URL(endpoint);
472
+ if (idToken) url.searchParams.set('id_token_hint', idToken);
473
+
474
+ const postLogout = options.postLogoutRedirectUri;
475
+ if (postLogout) url.searchParams.set('post_logout_redirect_uri', postLogout);
476
+
477
+ const target = url.toString();
478
+ const shouldRedirect = options.redirect ?? true;
479
+ if (shouldRedirect) globalThis.location?.assign(target);
480
+
481
+ return target;
482
+ }
483
+
484
+ // ── Interne ────────────────────────────────────────────────────────────────
485
+
486
+ #isExpired(tokens: Tokens): boolean {
487
+ return Date.now() >= tokens.expiresAt - this.#skewMs;
488
+ }
489
+
490
+ #refreshKey(): string {
491
+ return `${this.#prefix}:rt`;
492
+ }
493
+
494
+ #transactionKey(state: string): string {
495
+ return `${this.#prefix}:tx:${state}`;
496
+ }
497
+
498
+ #saveTransaction(transaction: Transaction): void {
499
+ this.#transactionStore.set(this.#transactionKey(transaction.state), JSON.stringify(transaction));
500
+ }
501
+
502
+ #takeTransaction(state: string): Transaction | null {
503
+ const key = this.#transactionKey(state);
504
+ const raw = this.#transactionStore.get(key);
505
+ this.#transactionStore.remove(key);
506
+ if (!raw) return null;
507
+
508
+ try {
509
+ const parsed = JSON.parse(raw) as Transaction;
510
+ if (typeof parsed.state !== 'string' || typeof parsed.verifier !== 'string') return null;
511
+ return parsed;
512
+ } catch {
513
+ return null;
514
+ }
515
+ }
516
+
517
+ #restoreTokens(): Tokens | null {
518
+ // Seul le refresh token est éventuellement persisté : l'access token reste
519
+ // en mémoire, où un XSS doit au moins travailler pour aller le chercher.
520
+ const refreshToken = this.#tokenStore.get(this.#refreshKey());
521
+ if (!refreshToken) return null;
522
+ return {
523
+ accessToken: '',
524
+ tokenType: 'Bearer',
525
+ expiresAt: 0,
526
+ refreshToken,
527
+ idToken: null,
528
+ scope: [],
529
+ };
530
+ }
531
+
532
+ #storeTokens(tokens: Tokens): void {
533
+ this.#tokens = tokens;
534
+ // Le contexte dépend des droits du porteur : un nouveau jeton peut porter
535
+ // de nouveaux rôles.
536
+ this.#context = null;
537
+ this.#contextFetchedAt = 0;
538
+
539
+ if (tokens.refreshToken) {
540
+ this.#tokenStore.set(this.#refreshKey(), tokens.refreshToken);
541
+ } else {
542
+ this.#tokenStore.remove(this.#refreshKey());
543
+ }
544
+ }
545
+
546
+ #clearSession(): void {
547
+ this.#tokens = null;
548
+ this.#user = null;
549
+ this.#context = null;
550
+ this.#contextFetchedAt = 0;
551
+ this.#tokenStore.remove(this.#refreshKey());
552
+ }
553
+
554
+ /** Émet `error` avant de rendre l'erreur, pour que les abonnés la voient aussi. */
555
+ #fail(error: AscenciaError): AscenciaError {
556
+ this.#emit('error', error);
557
+ return error;
558
+ }
559
+
560
+ async #postToken(fields: Record<string, string>): Promise<Tokens> {
561
+ const metadata = await this.metadata();
562
+
563
+ const body = new URLSearchParams(fields);
564
+ body.set('client_id', this.#config.clientId);
565
+ // `client_secret_post` plutôt que Basic : pas de base64 à fabriquer, et le
566
+ // serveur accepte les deux (apps/id/src/lib/oauth/clients.ts).
567
+ if (this.#config.clientSecret) body.set('client_secret', this.#config.clientSecret);
568
+
569
+ let response: Response;
570
+ try {
571
+ response = await this.#fetch(metadata.token_endpoint, {
572
+ method: 'POST',
573
+ headers: {
574
+ 'content-type': 'application/x-www-form-urlencoded',
575
+ accept: 'application/json',
576
+ },
577
+ body: body.toString(),
578
+ });
579
+ } catch (cause) {
580
+ throw this.#fail(
581
+ new AscenciaError('network_error', "Le point d'accès jeton est injoignable.", { cause }),
582
+ );
583
+ }
584
+
585
+ let payload: TokenEndpointResponse = {};
586
+ try {
587
+ payload = (await response.json()) as TokenEndpointResponse;
588
+ } catch {
589
+ // Un 204 ou une réponse tronquée : le contrôle de statut ci-dessous
590
+ // produira un message plus utile qu'une erreur de parsing.
591
+ }
592
+
593
+ if (!response.ok || payload.error) {
594
+ const code = asString(payload.error) ?? 'token_request_failed';
595
+ const description = asString(payload.error_description);
596
+ throw this.#fail(
597
+ new AscenciaError(code, description ?? `Échec de la requête jeton (${response.status}).`, {
598
+ description,
599
+ status: response.status,
600
+ }),
601
+ );
602
+ }
603
+
604
+ const accessToken = asString(payload.access_token);
605
+ if (!accessToken) {
606
+ throw this.#fail(
607
+ new AscenciaError('invalid_token_response', "La réponse ne contient pas d'access_token."),
608
+ );
609
+ }
610
+
611
+ const expiresIn = typeof payload.expires_in === 'number' ? payload.expires_in : 600;
612
+
613
+ return {
614
+ accessToken,
615
+ tokenType: asString(payload.token_type) ?? 'Bearer',
616
+ expiresAt: Date.now() + expiresIn * 1000,
617
+ refreshToken: asString(payload.refresh_token),
618
+ idToken: asString(payload.id_token),
619
+ scope: asString(payload.scope)?.split(' ').filter(Boolean) ?? [],
620
+ };
621
+ }
622
+
623
+ /** `null` quand il n'y a pas de jeton, ou quand l'IdP répond 401 / 403. */
624
+ async #authorizedJson<T>(url: string): Promise<T | null> {
625
+ const accessToken = await this.getAccessToken();
626
+ if (!accessToken) return null;
627
+
628
+ let response: Response;
629
+ try {
630
+ response = await this.#fetch(url, {
631
+ headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
632
+ });
633
+ } catch (cause) {
634
+ throw this.#fail(new AscenciaError('network_error', `${url} est injoignable.`, { cause }));
635
+ }
636
+
637
+ if (response.status === 401 || response.status === 403) return null;
638
+
639
+ if (!response.ok) {
640
+ throw this.#fail(
641
+ new AscenciaError('request_failed', `${url} a répondu ${response.status}.`, {
642
+ status: response.status,
643
+ }),
644
+ );
645
+ }
646
+
647
+ try {
648
+ return (await response.json()) as T;
649
+ } catch (cause) {
650
+ throw this.#fail(new AscenciaError('invalid_response', `Réponse illisible de ${url}.`, { cause }));
651
+ }
652
+ }
653
+ }
654
+
655
+ export function createClient(config: AscenciaConfig): AscenciaClient {
656
+ return new AscenciaClient(config);
657
+ }