@hgdc/auth 0.8.0 → 0.10.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/README.md +37 -9
- package/dist/events.d.ts +65 -12
- package/dist/events.js +153 -32
- package/dist/index.d.ts +39 -0
- package/dist/index.js +57 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Connexion d'un produit de la famille à Account (OIDC) : bouton « Se connecter avec H family », configuration du client better-auth, proxy `/api/auth` pour TanStack Start et gardes Convex.
|
|
4
4
|
|
|
5
|
-
| Import | Contenu
|
|
6
|
-
| ------------------- |
|
|
7
|
-
| `@hgdc/auth` | `HGDC_ISSUER`, `HGDC_PROVIDER_ID`, `HGDC_SCOPES`, entrée commune (`HGDC_ENTRY_PATH`, `safeNextPath`, `hgdcEntryUrl`), rôles (`highestRole`, `hasRole`), `parseHgdcProfile`, `verifiedOrganizationsFromIdToken`, `verifyHgdcIdToken`, `planOrganizationSync`, fraîcheur (`membershipIsFresh`, `SESSION_TTL_SECONDS`, `MEMBERSHIP_TTL_MS`, `KEY_MEMBERSHIP_TTL_MS`) |
|
|
8
|
-
| `@hgdc/auth/server` | `hgdcProvider` (plugin `genericOAuth`), `createAuthProxy`, `restoreFetchMode`, `createHgdcEntry`
|
|
9
|
-
| `@hgdc/auth/convex` | `requireIdentity`, `requireOrgMember`, `isAuthError`, `authErrorReason`, `AUTH_ERROR_MESSAGES`
|
|
10
|
-
| `@hgdc/auth/events` | Événements poussés par Account : `ACCOUNT_EVENTS_PATH`, `signAccountEvent`, `verifyAccountEvent`, `handleAccountEventRequest`, `accountEventSchema`, `accountEventIsStale`, `ACCOUNT_EVENT_RETENTION_MS`, et pour Convex `vAccountEvent`, `accountEventsTable`
|
|
11
|
-
| `@hgdc/auth/react` | `HgdcSignInButton`, `startHgdcSignIn`, `HgdcMark` (le H tricolore de la famille, `HGDC_MARK_SVG`)
|
|
5
|
+
| Import | Contenu |
|
|
6
|
+
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| `@hgdc/auth` | `HGDC_ISSUER`, `HGDC_PROVIDER_ID`, `HGDC_SCOPES`, entrée commune (`HGDC_ENTRY_PATH`, `safeNextPath`, `hgdcEntryUrl`), rôles (`highestRole`, `hasRole`), `parseHgdcProfile`, `verifiedOrganizationsFromIdToken`, `verifyHgdcIdToken`, `planOrganizationSync`, fraîcheur (`membershipIsFresh`, `SESSION_TTL_SECONDS`, `MEMBERSHIP_TTL_MS`, `KEY_MEMBERSHIP_TTL_MS`), `createSessionTokenCache` |
|
|
8
|
+
| `@hgdc/auth/server` | `hgdcProvider` (plugin `genericOAuth`), `createAuthProxy`, `restoreFetchMode`, `createHgdcEntry` |
|
|
9
|
+
| `@hgdc/auth/convex` | `requireIdentity`, `requireOrgMember`, `isAuthError`, `authErrorReason`, `AUTH_ERROR_MESSAGES` |
|
|
10
|
+
| `@hgdc/auth/events` | Événements poussés par Account : `ACCOUNT_EVENTS_PATH`, `signAccountEvent`, `verifyAccountEvent`, `handleAccountEventRequest`, `accountEventSchema`, `accountEventIsStale`, `ACCOUNT_EVENT_RETENTION_MS`, et pour Convex `vAccountEvent`, `accountEventsTable` |
|
|
11
|
+
| `@hgdc/auth/react` | `HgdcSignInButton`, `startHgdcSignIn`, `HgdcMark` (le H tricolore de la famille, `HGDC_MARK_SVG`) |
|
|
12
12
|
|
|
13
13
|
Pairs : better-auth 1.6, convex 1.46+, React 19 (chacun facultatif selon l'import).
|
|
14
14
|
|
|
@@ -131,6 +131,29 @@ if (!organization && !hasSyncedMarker(location.searchStr))
|
|
|
131
131
|
|
|
132
132
|
Décisions : ADR 0010, ADR 0013.
|
|
133
133
|
|
|
134
|
+
## Session dans le `beforeLoad` de la racine
|
|
135
|
+
|
|
136
|
+
Sans cache, chaque navigation côté navigateur rappelait le serveur (`getAuth`) avant tout rendu : l'ancienne page restait figée, et TanStack Router n'affiche aucun chargement pendant un `beforeLoad`. `createSessionTokenCache` reprend au navigateur une session ouverte pendant 5 minutes (le client Convex renouvelle lui-même son jeton), relit toujours au serveur, et s'amorce avec le jeton du rendu serveur :
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const session = createSessionTokenCache(() => getAuth())
|
|
140
|
+
|
|
141
|
+
export const Route = createRootRouteWithContext<…>()({
|
|
142
|
+
beforeLoad: async ({ context }) => {
|
|
143
|
+
const token = await session.read()
|
|
144
|
+
if (token) context.convexQueryClient.serverHttpClient?.setAuth(token)
|
|
145
|
+
return { isAuthenticated: Boolean(token), token }
|
|
146
|
+
},
|
|
147
|
+
component: () => {
|
|
148
|
+
const context = Route.useRouteContext()
|
|
149
|
+
useEffect(() => session.seed(context.token), [context.token])
|
|
150
|
+
…
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Une absence de session n'est jamais gardée. Déconnexion : recharger la page, ou `session.clear()`.
|
|
156
|
+
|
|
134
157
|
## Garder une fonction Convex
|
|
135
158
|
|
|
136
159
|
```ts
|
|
@@ -183,7 +206,7 @@ const claimed = verifiedOrganizationsFromIdToken(hgdc.idToken ?? "", {
|
|
|
183
206
|
now: Date.now(),
|
|
184
207
|
}) // lève HgdcIdTokenError : émetteur, audience (clientId), iat de plus de 10 minutes
|
|
185
208
|
const plan = planOrganizationSync(claimed, membershipsOfThisUser)
|
|
186
|
-
// mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = Date.now()
|
|
209
|
+
// mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = issuedAt partout (iat du jeton, verifyHgdcIdToken), jamais Date.now()
|
|
187
210
|
|
|
188
211
|
// chargeur de requireOrgMember
|
|
189
212
|
load: async (ctx, { organizationId, userId }) => {
|
|
@@ -207,6 +230,7 @@ http.route({
|
|
|
207
230
|
method: "POST",
|
|
208
231
|
handler: httpAction(async (ctx, request) =>
|
|
209
232
|
handleAccountEventRequest(request, {
|
|
233
|
+
audience: "host", // le produit : signé par Account, vérifié ici
|
|
210
234
|
secret: process.env.ACCOUNT_EVENTS_SECRET,
|
|
211
235
|
apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
|
|
212
236
|
}),
|
|
@@ -218,6 +242,7 @@ http.route({
|
|
|
218
242
|
// convex/organizations.ts
|
|
219
243
|
export const applyAccountEvent = internalMutation({
|
|
220
244
|
args: { event: vAccountEvent },
|
|
245
|
+
returns: vAccountEventOutcome, // "applied" | "duplicate" | "ignored:<raison>"
|
|
221
246
|
handler: async (ctx, { event }) => {
|
|
222
247
|
const seen = await ctx.db
|
|
223
248
|
.query("accountEvents")
|
|
@@ -229,12 +254,15 @@ export const applyAccountEvent = internalMutation({
|
|
|
229
254
|
// member.removed : retirer l'appartenance, sauf si accountEventIsStale(event, membre.syncedAt)
|
|
230
255
|
// member.role_changed : rôle et syncedAt = max(syncedAt, event.occurredAt) ; ne crée jamais d'appartenance
|
|
231
256
|
// organization.deleted : retirer toutes les appartenances, garder les données de l'espace
|
|
257
|
+
// rien à appliquer : "ignored:unknown_organization" | "ignored:unknown_user" | "ignored:not_member" | "ignored:stale"
|
|
232
258
|
return "applied"
|
|
233
259
|
},
|
|
234
260
|
})
|
|
235
261
|
```
|
|
236
262
|
|
|
237
|
-
Le secret (32 caractères au moins) est propre au produit : `ACCOUNT_EVENTS_SECRET` chez lui, `ACCOUNT_EVENTS_SECRET_<PRODUIT>` chez Account avec l'adresse de l'endpoint. Sans secret, l'endpoint répond 503 et Account réessaie (1, 5, 15, 60 minutes). Une signature invalide, un horodatage à plus de 5 minutes
|
|
263
|
+
Le secret (32 caractères au moins) est propre au produit : `ACCOUNT_EVENTS_SECRET` chez lui, `ACCOUNT_EVENTS_SECRET_<PRODUIT>` chez Account avec l'adresse de l'endpoint. La signature `v2=` couvre l'audience (`x-hgdc-audience`, le produit destinataire) : un événement signé pour un autre produit rend 401. Le corps est lu en flux et plafonné à 8 Ko (`readBodyText`, réutilisable). Sans secret, l'endpoint répond 503 et Account réessaie (1, 5, 15, 60 minutes). Une signature invalide ou d'une autre audience, ou un horodatage à plus de 5 minutes rendent 401 ; un corps invalide ou un `occurredAt` à plus de 5 minutes dans le futur, 400. La réponse 200 porte l'issue : `{"status":"applied"}`, `{"status":"duplicate"}` ou `{"status":"ignored","reason":…}` (`unknown_organization`, `unknown_user`, `not_member`, `stale`), journalisée en avertissement chez le produit ; Account remonte à GlitchTip un retrait ignoré pour une autre raison que `unknown_organization`. Les identifiants se purgent après `ACCOUNT_EVENT_RETENTION_MS` (cron, index `by_received`).
|
|
264
|
+
|
|
265
|
+
Ordre : `accountEventIsStale(event, syncedAt)` compare l'événement à `syncedAt`, qui doit être l'`iat` du jeton lu à la connexion (`verifyHgdcIdToken(...).issuedAt`) et non l'heure de la copie : un jeton émis avant un retrait mais lu après ne doit pas le masquer.
|
|
238
266
|
|
|
239
267
|
Les gardes doivent lire l'appartenance à chaque requête (chargeur de `requireOrgMember`) : c'est ce qui rend le retrait immédiat.
|
|
240
268
|
|
package/dist/events.d.ts
CHANGED
|
@@ -4,14 +4,21 @@ import { z } from "zod";
|
|
|
4
4
|
import { type OrgRole } from "./index.ts";
|
|
5
5
|
/** Chemin de l'endpoint de chaque produit, sur son déploiement Convex (`https://<…>.convex.site`). */
|
|
6
6
|
export declare const ACCOUNT_EVENTS_PATH = "/hgdc/v1/account-events";
|
|
7
|
+
/** Produits destinataires : l'audience signée de chaque événement. */
|
|
8
|
+
export declare const ACCOUNT_EVENT_AUDIENCES: readonly ["host", "mail", "status", "portal", "analytics", "docs"];
|
|
9
|
+
export type AccountEventAudience = (typeof ACCOUNT_EVENT_AUDIENCES)[number];
|
|
7
10
|
/** En-têtes de la requête signée. */
|
|
8
11
|
export declare const ACCOUNT_EVENT_HEADERS: {
|
|
9
12
|
readonly id: "x-hgdc-event-id";
|
|
10
13
|
readonly timestamp: "x-hgdc-timestamp";
|
|
14
|
+
readonly audience: "x-hgdc-audience";
|
|
11
15
|
readonly signature: "x-hgdc-signature";
|
|
16
|
+
readonly signatureV2: "x-hgdc-signature-v2";
|
|
12
17
|
};
|
|
13
18
|
/** Écart toléré entre l'horodatage signé et l'horloge du produit, en secondes. */
|
|
14
19
|
export declare const ACCOUNT_EVENT_TOLERANCE_SECONDS = 300;
|
|
20
|
+
/** Avance tolérée de `occurredAt` sur l'horloge du produit, en millisecondes : au-delà, refusé. */
|
|
21
|
+
export declare const ACCOUNT_EVENT_FUTURE_TOLERANCE_MS: number;
|
|
15
22
|
/** Taille maximale du corps, en octets. Un événement en pèse moins de 400. */
|
|
16
23
|
export declare const ACCOUNT_EVENT_MAX_BYTES: number;
|
|
17
24
|
/** Longueur minimale du secret partagé (32 octets aléatoires en base64url : 43 caractères). */
|
|
@@ -46,21 +53,56 @@ export type AccountEvent = (EventBase & {
|
|
|
46
53
|
});
|
|
47
54
|
export declare const accountEventSchema: z.ZodType<AccountEvent>;
|
|
48
55
|
export type AccountEventType = AccountEvent["type"];
|
|
49
|
-
/**
|
|
50
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Pourquoi un produit n'a rien appliqué :
|
|
58
|
+
* - `unknown_organization` : aucun espace pour cette organisation (le seul cas normal) ;
|
|
59
|
+
* - `unknown_user` : personne sans compte `hgdc` chez le produit ;
|
|
60
|
+
* - `not_member` : personne connue, sans appartenance à cet espace ;
|
|
61
|
+
* - `stale` : appartenance confirmée par Account après l'événement (`accountEventIsStale`).
|
|
62
|
+
*/
|
|
63
|
+
export declare const ACCOUNT_EVENT_IGNORED_REASONS: readonly ["unknown_organization", "unknown_user", "not_member", "stale"];
|
|
64
|
+
export type AccountEventIgnoredReason = (typeof ACCOUNT_EVENT_IGNORED_REASONS)[number];
|
|
65
|
+
/** Ce que le produit a fait de l'événement : `applied`, `duplicate` ou `ignored:<raison>`. */
|
|
66
|
+
export type AccountEventOutcome = "applied" | "duplicate" | `ignored:${AccountEventIgnoredReason}`;
|
|
67
|
+
/** Réponse 200 du produit. */
|
|
68
|
+
export type AccountEventResponse = {
|
|
69
|
+
status: "applied" | "duplicate";
|
|
70
|
+
} | {
|
|
71
|
+
status: "ignored";
|
|
72
|
+
reason: AccountEventIgnoredReason;
|
|
73
|
+
};
|
|
74
|
+
/** Côté Account : l'issue lue dans la réponse 200 d'un produit, ou `null` si illisible. */
|
|
75
|
+
export declare function accountEventOutcomeOf(body: unknown): AccountEventOutcome | null;
|
|
51
76
|
/** Identifiant d'événement neuf (UUID sans tirets), à garder pour tous les essais. */
|
|
52
77
|
export declare function newAccountEventId(): string;
|
|
53
78
|
/**
|
|
54
|
-
* Côté Account : corps et en-têtes d'un envoi
|
|
55
|
-
* avec le même événement (même `id`).
|
|
79
|
+
* Côté Account : corps et en-têtes d'un envoi à `audience`. À rappeler à chaque essai (horodatage
|
|
80
|
+
* neuf), avec le même événement (même `id`). `legacy` ajoute l'ancienne signature `v1=` (sans
|
|
81
|
+
* audience) pour un produit pas encore monté : `v1=` dans `x-hgdc-signature`, `v2=` dans
|
|
82
|
+
* `x-hgdc-signature-v2`. Sans `legacy`, `x-hgdc-signature` porte `v2=`.
|
|
56
83
|
*/
|
|
57
84
|
export declare function signAccountEvent(event: AccountEvent, options: {
|
|
58
85
|
secret: string;
|
|
59
86
|
now: number;
|
|
87
|
+
audience: AccountEventAudience;
|
|
88
|
+
legacy?: boolean;
|
|
60
89
|
}): Promise<{
|
|
61
90
|
body: string;
|
|
62
91
|
headers: Record<string, string>;
|
|
63
92
|
}>;
|
|
93
|
+
export type BodyText = {
|
|
94
|
+
ok: true;
|
|
95
|
+
text: string;
|
|
96
|
+
} | {
|
|
97
|
+
ok: false;
|
|
98
|
+
reason: "too_large";
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Lecture bornée du corps d'une requête : par morceaux, abandonnée dès qu'elle dépasse `maxBytes`
|
|
102
|
+
* (en octets, « é » en compte deux). `request.text()` lit tout avant qu'on puisse compter, y
|
|
103
|
+
* compris un envoi sans `content-length` (chunked).
|
|
104
|
+
*/
|
|
105
|
+
export declare function readBodyText(request: Request, maxBytes: number): Promise<BodyText>;
|
|
64
106
|
/** Pourquoi une requête est refusée. La réponse ne porte que le code, jamais le détail. */
|
|
65
107
|
export type AccountEventRejection = "too_large" | "unauthorized" | "invalid_body";
|
|
66
108
|
export type AccountEventVerification = {
|
|
@@ -72,13 +114,16 @@ export type AccountEventVerification = {
|
|
|
72
114
|
error: AccountEventRejection;
|
|
73
115
|
};
|
|
74
116
|
/**
|
|
75
|
-
* Côté produit :
|
|
76
|
-
*
|
|
77
|
-
*
|
|
117
|
+
* Côté produit : lit le corps en flux (plafond `ACCOUNT_EVENT_MAX_BYTES`), vérifie horodatage
|
|
118
|
+
* (±5 minutes), audience (celle du produit), signature `v2=` en temps constant, puis le corps
|
|
119
|
+
* (`accountEventSchema`), l'accord entre l'identifiant signé et celui du corps, et un
|
|
120
|
+
* `occurredAt` sans plus de 5 minutes d'avance. Ne dit rien du rejeu : à écarter par la table
|
|
121
|
+
* des événements traités.
|
|
78
122
|
*/
|
|
79
123
|
export declare function verifyAccountEvent(request: Request, options: {
|
|
80
124
|
secret: string;
|
|
81
125
|
now: number;
|
|
126
|
+
audience: AccountEventAudience;
|
|
82
127
|
}): Promise<AccountEventVerification>;
|
|
83
128
|
/**
|
|
84
129
|
* Endpoint complet d'un produit, à envelopper dans une `httpAction` Convex :
|
|
@@ -86,6 +131,7 @@ export declare function verifyAccountEvent(request: Request, options: {
|
|
|
86
131
|
* ```ts
|
|
87
132
|
* export const accountEvents = httpAction(async (ctx, request) =>
|
|
88
133
|
* handleAccountEventRequest(request, {
|
|
134
|
+
* audience: "host",
|
|
89
135
|
* secret: process.env.ACCOUNT_EVENTS_SECRET,
|
|
90
136
|
* apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
|
|
91
137
|
* }),
|
|
@@ -94,22 +140,29 @@ export declare function verifyAccountEvent(request: Request, options: {
|
|
|
94
140
|
* ```
|
|
95
141
|
*
|
|
96
142
|
* `apply` est une mutation interne qui, dans la même transaction, écarte un `id` déjà traité,
|
|
97
|
-
* l'enregistre et applique l'événement
|
|
98
|
-
*
|
|
143
|
+
* l'enregistre et applique l'événement ; elle rend son issue détaillée (`AccountEventOutcome`),
|
|
144
|
+
* renvoyée à Account et journalisée en avertissement quand rien n'est appliqué. Sans secret
|
|
145
|
+
* posé : 503, Account réessaie plus tard. Une erreur de `apply` remonte (500) : Account réessaie.
|
|
99
146
|
*/
|
|
100
147
|
export declare function handleAccountEventRequest(request: Request, options: {
|
|
148
|
+
audience: AccountEventAudience;
|
|
101
149
|
secret: string | undefined;
|
|
102
150
|
apply: (event: AccountEvent) => Promise<AccountEventOutcome>;
|
|
103
151
|
now?: number;
|
|
104
152
|
}): Promise<Response>;
|
|
105
153
|
/**
|
|
106
|
-
* Vrai si l'appartenance a été confirmée par Account après l'événement
|
|
107
|
-
*
|
|
108
|
-
*
|
|
154
|
+
* Vrai si l'appartenance a été confirmée par Account après l'événement : un essai en retard ne
|
|
155
|
+
* doit pas défaire une connexion plus récente (personne retirée puis réinvitée, rôle changé deux
|
|
156
|
+
* fois). `syncedAt` doit être l'instant où Account a émis les droits recopiés, soit l'`iat` de
|
|
157
|
+
* l'id_token (`verifyHgdcIdToken(...).issuedAt`), jamais l'heure de la copie : un jeton émis avant
|
|
158
|
+
* un retrait puis lu après ne doit pas masquer ce retrait. Une copie sans `syncedAt` n'est jamais
|
|
159
|
+
* plus récente.
|
|
109
160
|
*/
|
|
110
161
|
export declare function accountEventIsStale(event: Pick<AccountEvent, "occurredAt">, syncedAt: number | null | undefined): boolean;
|
|
111
162
|
/** Validateur Convex d'un `AccountEvent` : argument de la mutation interne qui l'applique. */
|
|
112
163
|
export declare const vAccountEvent: Validator<AccountEvent, "required", string>;
|
|
164
|
+
/** Validateur Convex de l'issue rendue par la mutation qui applique un événement. */
|
|
165
|
+
export declare const vAccountEventOutcome: Validator<AccountEventOutcome, "required", string>;
|
|
113
166
|
type AccountEventRow = {
|
|
114
167
|
eventId: string;
|
|
115
168
|
type: string;
|
package/dist/events.js
CHANGED
|
@@ -8,20 +8,36 @@ import { ORG_ROLES } from "./index.js";
|
|
|
8
8
|
* relecture des organisations à la connexion suivante.
|
|
9
9
|
*
|
|
10
10
|
* Signature HMAC-SHA256 par produit, Web Crypto seulement (Convex, Node, Bun, navigateurs) :
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* `v2=` hex de HMAC(secret, "v2.<audience>.<id>.<horodatage>.<corps brut>"). L'audience (le
|
|
12
|
+
* produit destinataire) et l'identifiant de l'événement sont dans le MAC : un événement destiné à
|
|
13
|
+
* un autre produit est refusé même si deux secrets venaient à se confondre, et rejouer une requête
|
|
14
|
+
* captée dans la fenêtre de 5 minutes ne passe pas la table des événements déjà traités.
|
|
15
|
+
* L'ancien format `v1=` (sans audience) n'est plus accepté à la réception ; Account peut encore
|
|
16
|
+
* l'ajouter (`legacy`) le temps que tous les produits soient montés.
|
|
14
17
|
*/
|
|
15
18
|
/** Chemin de l'endpoint de chaque produit, sur son déploiement Convex (`https://<…>.convex.site`). */
|
|
16
19
|
export const ACCOUNT_EVENTS_PATH = "/hgdc/v1/account-events";
|
|
20
|
+
/** Produits destinataires : l'audience signée de chaque événement. */
|
|
21
|
+
export const ACCOUNT_EVENT_AUDIENCES = [
|
|
22
|
+
"host",
|
|
23
|
+
"mail",
|
|
24
|
+
"status",
|
|
25
|
+
"portal",
|
|
26
|
+
"analytics",
|
|
27
|
+
"docs",
|
|
28
|
+
];
|
|
17
29
|
/** En-têtes de la requête signée. */
|
|
18
30
|
export const ACCOUNT_EVENT_HEADERS = {
|
|
19
31
|
id: "x-hgdc-event-id",
|
|
20
32
|
timestamp: "x-hgdc-timestamp",
|
|
33
|
+
audience: "x-hgdc-audience",
|
|
21
34
|
signature: "x-hgdc-signature",
|
|
35
|
+
signatureV2: "x-hgdc-signature-v2",
|
|
22
36
|
};
|
|
23
37
|
/** Écart toléré entre l'horodatage signé et l'horloge du produit, en secondes. */
|
|
24
38
|
export const ACCOUNT_EVENT_TOLERANCE_SECONDS = 300;
|
|
39
|
+
/** Avance tolérée de `occurredAt` sur l'horloge du produit, en millisecondes : au-delà, refusé. */
|
|
40
|
+
export const ACCOUNT_EVENT_FUTURE_TOLERANCE_MS = ACCOUNT_EVENT_TOLERANCE_SECONDS * 1000;
|
|
25
41
|
/** Taille maximale du corps, en octets. Un événement en pèse moins de 400. */
|
|
26
42
|
export const ACCOUNT_EVENT_MAX_BYTES = 8 * 1024;
|
|
27
43
|
/** Longueur minimale du secret partagé (32 octets aléatoires en base64url : 43 caractères). */
|
|
@@ -55,6 +71,39 @@ export const accountEventSchema = z.discriminatedUnion("type", [
|
|
|
55
71
|
type: z.literal("organization.deleted"),
|
|
56
72
|
}),
|
|
57
73
|
]);
|
|
74
|
+
/**
|
|
75
|
+
* Pourquoi un produit n'a rien appliqué :
|
|
76
|
+
* - `unknown_organization` : aucun espace pour cette organisation (le seul cas normal) ;
|
|
77
|
+
* - `unknown_user` : personne sans compte `hgdc` chez le produit ;
|
|
78
|
+
* - `not_member` : personne connue, sans appartenance à cet espace ;
|
|
79
|
+
* - `stale` : appartenance confirmée par Account après l'événement (`accountEventIsStale`).
|
|
80
|
+
*/
|
|
81
|
+
export const ACCOUNT_EVENT_IGNORED_REASONS = [
|
|
82
|
+
"unknown_organization",
|
|
83
|
+
"unknown_user",
|
|
84
|
+
"not_member",
|
|
85
|
+
"stale",
|
|
86
|
+
];
|
|
87
|
+
function isIgnoredReason(value) {
|
|
88
|
+
return ACCOUNT_EVENT_IGNORED_REASONS.includes(value);
|
|
89
|
+
}
|
|
90
|
+
function responseOf(outcome) {
|
|
91
|
+
if (outcome === "applied" || outcome === "duplicate")
|
|
92
|
+
return { status: outcome };
|
|
93
|
+
const reason = outcome.slice("ignored:".length);
|
|
94
|
+
return { status: "ignored", reason: isIgnoredReason(reason) ? reason : "not_member" };
|
|
95
|
+
}
|
|
96
|
+
/** Côté Account : l'issue lue dans la réponse 200 d'un produit, ou `null` si illisible. */
|
|
97
|
+
export function accountEventOutcomeOf(body) {
|
|
98
|
+
if (typeof body !== "object" || body === null)
|
|
99
|
+
return null;
|
|
100
|
+
const { status, reason } = body;
|
|
101
|
+
if (status === "applied" || status === "duplicate")
|
|
102
|
+
return status;
|
|
103
|
+
if (status === "ignored" && isIgnoredReason(reason))
|
|
104
|
+
return `ignored:${reason}`;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
58
107
|
const encoder = new TextEncoder();
|
|
59
108
|
async function hmacHex(secret, payload) {
|
|
60
109
|
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
@@ -75,29 +124,77 @@ function assertSecret(secret) {
|
|
|
75
124
|
if (typeof secret !== "string" || secret.length < ACCOUNT_EVENT_SECRET_MIN_LENGTH)
|
|
76
125
|
throw new Error(`Secret des événements Account absent ou trop court (${ACCOUNT_EVENT_SECRET_MIN_LENGTH} caractères au moins).`);
|
|
77
126
|
}
|
|
127
|
+
function assertAudience(audience) {
|
|
128
|
+
if (!ACCOUNT_EVENT_AUDIENCES.includes(audience))
|
|
129
|
+
throw new Error("Audience d'événement Account inconnue.");
|
|
130
|
+
}
|
|
131
|
+
function signedPayloadV2(audience, eventId, timestamp, body) {
|
|
132
|
+
return `v2.${audience}.${eventId}.${timestamp}.${body}`;
|
|
133
|
+
}
|
|
78
134
|
/** Identifiant d'événement neuf (UUID sans tirets), à garder pour tous les essais. */
|
|
79
135
|
export function newAccountEventId() {
|
|
80
136
|
return crypto.randomUUID().replaceAll("-", "");
|
|
81
137
|
}
|
|
82
138
|
/**
|
|
83
|
-
* Côté Account : corps et en-têtes d'un envoi
|
|
84
|
-
* avec le même événement (même `id`).
|
|
139
|
+
* Côté Account : corps et en-têtes d'un envoi à `audience`. À rappeler à chaque essai (horodatage
|
|
140
|
+
* neuf), avec le même événement (même `id`). `legacy` ajoute l'ancienne signature `v1=` (sans
|
|
141
|
+
* audience) pour un produit pas encore monté : `v1=` dans `x-hgdc-signature`, `v2=` dans
|
|
142
|
+
* `x-hgdc-signature-v2`. Sans `legacy`, `x-hgdc-signature` porte `v2=`.
|
|
85
143
|
*/
|
|
86
144
|
export async function signAccountEvent(event, options) {
|
|
87
145
|
assertSecret(options.secret);
|
|
146
|
+
assertAudience(options.audience);
|
|
88
147
|
const parsed = accountEventSchema.parse(event);
|
|
89
148
|
const body = JSON.stringify(parsed);
|
|
90
149
|
const timestamp = String(Math.floor(options.now / 1000));
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
[ACCOUNT_EVENT_HEADERS.timestamp]: timestamp,
|
|
98
|
-
[ACCOUNT_EVENT_HEADERS.signature]: signature,
|
|
99
|
-
},
|
|
150
|
+
const v2 = `v2=${await hmacHex(options.secret, signedPayloadV2(options.audience, parsed.id, timestamp, body))}`;
|
|
151
|
+
const headers = {
|
|
152
|
+
"content-type": "application/json",
|
|
153
|
+
[ACCOUNT_EVENT_HEADERS.id]: parsed.id,
|
|
154
|
+
[ACCOUNT_EVENT_HEADERS.timestamp]: timestamp,
|
|
155
|
+
[ACCOUNT_EVENT_HEADERS.audience]: options.audience,
|
|
100
156
|
};
|
|
157
|
+
if (options.legacy) {
|
|
158
|
+
headers[ACCOUNT_EVENT_HEADERS.signature] =
|
|
159
|
+
`v1=${await hmacHex(options.secret, `${parsed.id}.${timestamp}.${body}`)}`;
|
|
160
|
+
headers[ACCOUNT_EVENT_HEADERS.signatureV2] = v2;
|
|
161
|
+
}
|
|
162
|
+
else
|
|
163
|
+
headers[ACCOUNT_EVENT_HEADERS.signature] = v2;
|
|
164
|
+
return { body, headers };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Lecture bornée du corps d'une requête : par morceaux, abandonnée dès qu'elle dépasse `maxBytes`
|
|
168
|
+
* (en octets, « é » en compte deux). `request.text()` lit tout avant qu'on puisse compter, y
|
|
169
|
+
* compris un envoi sans `content-length` (chunked).
|
|
170
|
+
*/
|
|
171
|
+
export async function readBodyText(request, maxBytes) {
|
|
172
|
+
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
173
|
+
if (declared > maxBytes)
|
|
174
|
+
return { ok: false, reason: "too_large" };
|
|
175
|
+
if (!request.body)
|
|
176
|
+
return { ok: true, text: "" };
|
|
177
|
+
const reader = request.body.getReader();
|
|
178
|
+
const chunks = [];
|
|
179
|
+
let size = 0;
|
|
180
|
+
for (;;) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done)
|
|
183
|
+
break;
|
|
184
|
+
size += value.byteLength;
|
|
185
|
+
if (size > maxBytes) {
|
|
186
|
+
await reader.cancel().catch(() => undefined);
|
|
187
|
+
return { ok: false, reason: "too_large" };
|
|
188
|
+
}
|
|
189
|
+
chunks.push(value);
|
|
190
|
+
}
|
|
191
|
+
const bytes = new Uint8Array(size);
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const chunk of chunks) {
|
|
194
|
+
bytes.set(chunk, offset);
|
|
195
|
+
offset += chunk.byteLength;
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, text: new TextDecoder().decode(bytes) };
|
|
101
198
|
}
|
|
102
199
|
const reject = (status, error) => ({
|
|
103
200
|
ok: false,
|
|
@@ -105,26 +202,33 @@ const reject = (status, error) => ({
|
|
|
105
202
|
error,
|
|
106
203
|
});
|
|
107
204
|
/**
|
|
108
|
-
* Côté produit :
|
|
109
|
-
*
|
|
110
|
-
*
|
|
205
|
+
* Côté produit : lit le corps en flux (plafond `ACCOUNT_EVENT_MAX_BYTES`), vérifie horodatage
|
|
206
|
+
* (±5 minutes), audience (celle du produit), signature `v2=` en temps constant, puis le corps
|
|
207
|
+
* (`accountEventSchema`), l'accord entre l'identifiant signé et celui du corps, et un
|
|
208
|
+
* `occurredAt` sans plus de 5 minutes d'avance. Ne dit rien du rejeu : à écarter par la table
|
|
209
|
+
* des événements traités.
|
|
111
210
|
*/
|
|
112
211
|
export async function verifyAccountEvent(request, options) {
|
|
113
212
|
assertSecret(options.secret);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const raw = await request.text();
|
|
118
|
-
if (encoder.encode(raw).byteLength > ACCOUNT_EVENT_MAX_BYTES)
|
|
213
|
+
assertAudience(options.audience);
|
|
214
|
+
const read = await readBodyText(request, ACCOUNT_EVENT_MAX_BYTES);
|
|
215
|
+
if (!read.ok)
|
|
119
216
|
return reject(413, "too_large");
|
|
217
|
+
const raw = read.text;
|
|
120
218
|
const eventId = request.headers.get(ACCOUNT_EVENT_HEADERS.id) ?? "";
|
|
121
219
|
const timestamp = request.headers.get(ACCOUNT_EVENT_HEADERS.timestamp) ?? "";
|
|
122
|
-
const
|
|
220
|
+
const audience = request.headers.get(ACCOUNT_EVENT_HEADERS.audience) ?? "";
|
|
221
|
+
const signature = [
|
|
222
|
+
request.headers.get(ACCOUNT_EVENT_HEADERS.signatureV2),
|
|
223
|
+
request.headers.get(ACCOUNT_EVENT_HEADERS.signature),
|
|
224
|
+
].find((value) => value?.startsWith("v2="));
|
|
225
|
+
if (!signature || audience !== options.audience)
|
|
226
|
+
return reject(401, "unauthorized");
|
|
123
227
|
if (!/^[A-Za-z0-9_-]{16,64}$/.test(eventId) || !/^\d{1,12}$/.test(timestamp))
|
|
124
228
|
return reject(401, "unauthorized");
|
|
125
229
|
if (Math.abs(options.now / 1000 - Number(timestamp)) > ACCOUNT_EVENT_TOLERANCE_SECONDS)
|
|
126
230
|
return reject(401, "unauthorized");
|
|
127
|
-
const expected = `
|
|
231
|
+
const expected = `v2=${await hmacHex(options.secret, signedPayloadV2(options.audience, eventId, timestamp, raw))}`;
|
|
128
232
|
if (!constantTimeEqual(signature, expected))
|
|
129
233
|
return reject(401, "unauthorized");
|
|
130
234
|
let json;
|
|
@@ -137,6 +241,8 @@ export async function verifyAccountEvent(request, options) {
|
|
|
137
241
|
const parsed = accountEventSchema.safeParse(json);
|
|
138
242
|
if (!parsed.success || parsed.data.id !== eventId)
|
|
139
243
|
return reject(400, "invalid_body");
|
|
244
|
+
if (parsed.data.occurredAt > options.now + ACCOUNT_EVENT_FUTURE_TOLERANCE_MS)
|
|
245
|
+
return reject(400, "invalid_body");
|
|
140
246
|
return { ok: true, event: parsed.data };
|
|
141
247
|
}
|
|
142
248
|
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
@@ -146,6 +252,7 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
|
|
|
146
252
|
* ```ts
|
|
147
253
|
* export const accountEvents = httpAction(async (ctx, request) =>
|
|
148
254
|
* handleAccountEventRequest(request, {
|
|
255
|
+
* audience: "host",
|
|
149
256
|
* secret: process.env.ACCOUNT_EVENTS_SECRET,
|
|
150
257
|
* apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
|
|
151
258
|
* }),
|
|
@@ -154,23 +261,35 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
|
|
|
154
261
|
* ```
|
|
155
262
|
*
|
|
156
263
|
* `apply` est une mutation interne qui, dans la même transaction, écarte un `id` déjà traité,
|
|
157
|
-
* l'enregistre et applique l'événement
|
|
158
|
-
*
|
|
264
|
+
* l'enregistre et applique l'événement ; elle rend son issue détaillée (`AccountEventOutcome`),
|
|
265
|
+
* renvoyée à Account et journalisée en avertissement quand rien n'est appliqué. Sans secret
|
|
266
|
+
* posé : 503, Account réessaie plus tard. Une erreur de `apply` remonte (500) : Account réessaie.
|
|
159
267
|
*/
|
|
160
268
|
export async function handleAccountEventRequest(request, options) {
|
|
161
269
|
const { secret } = options;
|
|
162
270
|
if (typeof secret !== "string" || secret.length < ACCOUNT_EVENT_SECRET_MIN_LENGTH)
|
|
163
271
|
return json(503, { error: "not_configured" });
|
|
164
|
-
const verified = await verifyAccountEvent(request, {
|
|
272
|
+
const verified = await verifyAccountEvent(request, {
|
|
273
|
+
secret,
|
|
274
|
+
now: options.now ?? Date.now(),
|
|
275
|
+
audience: options.audience,
|
|
276
|
+
});
|
|
165
277
|
if (!verified.ok)
|
|
166
278
|
return json(verified.status, { error: verified.error });
|
|
167
|
-
const
|
|
168
|
-
|
|
279
|
+
const { event } = verified;
|
|
280
|
+
const outcome = await options.apply(event);
|
|
281
|
+
// Des identifiants seulement, jamais d'adresse : de quoi retrouver l'événement chez Account.
|
|
282
|
+
if (outcome !== "applied")
|
|
283
|
+
console.warn("Événement Account non appliqué", JSON.stringify({ id: event.id, type: event.type, organizationId: event.organizationId, outcome }));
|
|
284
|
+
return json(200, responseOf(outcome));
|
|
169
285
|
}
|
|
170
286
|
/**
|
|
171
|
-
* Vrai si l'appartenance a été confirmée par Account après l'événement
|
|
172
|
-
*
|
|
173
|
-
*
|
|
287
|
+
* Vrai si l'appartenance a été confirmée par Account après l'événement : un essai en retard ne
|
|
288
|
+
* doit pas défaire une connexion plus récente (personne retirée puis réinvitée, rôle changé deux
|
|
289
|
+
* fois). `syncedAt` doit être l'instant où Account a émis les droits recopiés, soit l'`iat` de
|
|
290
|
+
* l'id_token (`verifyHgdcIdToken(...).issuedAt`), jamais l'heure de la copie : un jeton émis avant
|
|
291
|
+
* un retrait puis lu après ne doit pas masquer ce retrait. Une copie sans `syncedAt` n'est jamais
|
|
292
|
+
* plus récente.
|
|
174
293
|
*/
|
|
175
294
|
export function accountEventIsStale(event, syncedAt) {
|
|
176
295
|
return typeof syncedAt === "number" && syncedAt > event.occurredAt;
|
|
@@ -197,6 +316,8 @@ export const vAccountEvent = v.union(v.object({
|
|
|
197
316
|
occurredAt: v.number(),
|
|
198
317
|
organizationId: v.string(),
|
|
199
318
|
}));
|
|
319
|
+
/** Validateur Convex de l'issue rendue par la mutation qui applique un événement. */
|
|
320
|
+
export const vAccountEventOutcome = v.union(v.literal("applied"), v.literal("duplicate"), v.literal("ignored:unknown_organization"), v.literal("ignored:unknown_user"), v.literal("ignored:not_member"), v.literal("ignored:stale"));
|
|
200
321
|
/**
|
|
201
322
|
* Table des événements déjà traités, pour l'idempotence (`accountEvents: accountEventsTable` dans
|
|
202
323
|
* le schéma du produit). Purge après `ACCOUNT_EVENT_RETENTION_MS` par l'index `by_received`.
|
package/dist/index.d.ts
CHANGED
|
@@ -141,10 +141,15 @@ export type IdTokenExpectation = {
|
|
|
141
141
|
* La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
|
|
142
142
|
* serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
|
|
143
143
|
* better-auth n'aurait pas réécrit, rejoue des organisations périmées.
|
|
144
|
+
*
|
|
145
|
+
* `issuedAt` (`iat` en millisecondes) est l'instant où Account a lu les organisations : c'est lui,
|
|
146
|
+
* pas l'heure de la copie, qui doit devenir le `syncedAt` des appartenances recopiées
|
|
147
|
+
* (`accountEventIsStale` le compare aux événements poussés par Account).
|
|
144
148
|
*/
|
|
145
149
|
export declare function verifyHgdcIdToken(idToken: string, expected: IdTokenExpectation): {
|
|
146
150
|
claims: Record<string, unknown>;
|
|
147
151
|
organizations: HgdcOrganization[];
|
|
152
|
+
issuedAt: number;
|
|
148
153
|
};
|
|
149
154
|
/**
|
|
150
155
|
* Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
|
|
@@ -185,3 +190,37 @@ export declare const KEY_MEMBERSHIP_TTL_MS: number;
|
|
|
185
190
|
export declare function membershipIsFresh(membership: {
|
|
186
191
|
syncedAt?: number | null | undefined;
|
|
187
192
|
}, now: number, ttl?: number): boolean;
|
|
193
|
+
/**
|
|
194
|
+
* Durée pendant laquelle le navigateur reprend le jeton de session déjà lu plutôt que de le
|
|
195
|
+
* redemander au serveur : 5 minutes. Le client Convex renouvelle lui-même son jeton ; celui-ci ne
|
|
196
|
+
* sert qu'aux gardes de route et à amorcer le client.
|
|
197
|
+
*/
|
|
198
|
+
export declare const CLIENT_SESSION_TOKEN_TTL_MS: number;
|
|
199
|
+
export type SessionTokenCache = {
|
|
200
|
+
/** Jeton courant : au serveur, toujours relu ; au navigateur, repris s'il a moins de 5 minutes. */
|
|
201
|
+
read: () => Promise<string | null>;
|
|
202
|
+
/** Amorce le cache avec le jeton du rendu serveur, à l'hydratation (la racine ne repasse pas par `read`). */
|
|
203
|
+
seed: (token: string | null | undefined) => void;
|
|
204
|
+
/** Oublie le jeton (déconnexion sans rechargement). */
|
|
205
|
+
clear: () => void;
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Jeton de session pour le `beforeLoad` de la racine d'un produit TanStack Start. Sans cache, chaque
|
|
209
|
+
* navigation côté navigateur rappelait le serveur (`getAuth`) avant tout rendu : l'ancienne page
|
|
210
|
+
* restait figée le temps de l'aller-retour. Seule une session ouverte est gardée, jamais une absence
|
|
211
|
+
* de session ; les appels simultanés partagent la même requête.
|
|
212
|
+
*
|
|
213
|
+
* ```ts
|
|
214
|
+
* const session = createSessionTokenCache(() => getAuth())
|
|
215
|
+
* beforeLoad: async ({ context }) => {
|
|
216
|
+
* const token = await session.read()
|
|
217
|
+
* …
|
|
218
|
+
* }
|
|
219
|
+
* // composant racine : useEffect(() => session.seed(context.token), [context.token])
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
export declare function createSessionTokenCache(fetchToken: () => Promise<string | null>, options?: {
|
|
223
|
+
ttlMs?: number;
|
|
224
|
+
now?: () => number;
|
|
225
|
+
isBrowser?: () => boolean;
|
|
226
|
+
}): SessionTokenCache;
|
package/dist/index.js
CHANGED
|
@@ -243,6 +243,10 @@ function payloadOf(idToken) {
|
|
|
243
243
|
* La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
|
|
244
244
|
* serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
|
|
245
245
|
* better-auth n'aurait pas réécrit, rejoue des organisations périmées.
|
|
246
|
+
*
|
|
247
|
+
* `issuedAt` (`iat` en millisecondes) est l'instant où Account a lu les organisations : c'est lui,
|
|
248
|
+
* pas l'heure de la copie, qui doit devenir le `syncedAt` des appartenances recopiées
|
|
249
|
+
* (`accountEventIsStale` le compare aux événements poussés par Account).
|
|
246
250
|
*/
|
|
247
251
|
export function verifyHgdcIdToken(idToken, expected) {
|
|
248
252
|
if (!expected.issuer || !expected.clientId)
|
|
@@ -265,7 +269,7 @@ export function verifyHgdcIdToken(idToken, expected) {
|
|
|
265
269
|
const result = organizationsClaim.safeParse(claims);
|
|
266
270
|
if (!result.success)
|
|
267
271
|
throw new HgdcIdTokenError("invalid_claims", `Organisations HGDC invalides : ${z.prettifyError(result.error)}`);
|
|
268
|
-
return { claims, organizations: result.data.organizations ?? [] };
|
|
272
|
+
return { claims, organizations: result.data.organizations ?? [], issuedAt: iat * 1000 };
|
|
269
273
|
}
|
|
270
274
|
/**
|
|
271
275
|
* Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
|
|
@@ -309,3 +313,55 @@ export function membershipIsFresh(membership, now, ttl = MEMBERSHIP_TTL_MS) {
|
|
|
309
313
|
const { syncedAt } = membership;
|
|
310
314
|
return typeof syncedAt === "number" && now - syncedAt <= ttl;
|
|
311
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Durée pendant laquelle le navigateur reprend le jeton de session déjà lu plutôt que de le
|
|
318
|
+
* redemander au serveur : 5 minutes. Le client Convex renouvelle lui-même son jeton ; celui-ci ne
|
|
319
|
+
* sert qu'aux gardes de route et à amorcer le client.
|
|
320
|
+
*/
|
|
321
|
+
export const CLIENT_SESSION_TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
322
|
+
/**
|
|
323
|
+
* Jeton de session pour le `beforeLoad` de la racine d'un produit TanStack Start. Sans cache, chaque
|
|
324
|
+
* navigation côté navigateur rappelait le serveur (`getAuth`) avant tout rendu : l'ancienne page
|
|
325
|
+
* restait figée le temps de l'aller-retour. Seule une session ouverte est gardée, jamais une absence
|
|
326
|
+
* de session ; les appels simultanés partagent la même requête.
|
|
327
|
+
*
|
|
328
|
+
* ```ts
|
|
329
|
+
* const session = createSessionTokenCache(() => getAuth())
|
|
330
|
+
* beforeLoad: async ({ context }) => {
|
|
331
|
+
* const token = await session.read()
|
|
332
|
+
* …
|
|
333
|
+
* }
|
|
334
|
+
* // composant racine : useEffect(() => session.seed(context.token), [context.token])
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
export function createSessionTokenCache(fetchToken, options = {}) {
|
|
338
|
+
const ttl = options.ttlMs ?? CLIENT_SESSION_TOKEN_TTL_MS;
|
|
339
|
+
const now = options.now ?? Date.now;
|
|
340
|
+
const isBrowser = options.isBrowser ?? (() => typeof document !== "undefined");
|
|
341
|
+
let cached = null;
|
|
342
|
+
let inflight = null;
|
|
343
|
+
return {
|
|
344
|
+
read: async () => {
|
|
345
|
+
if (!isBrowser())
|
|
346
|
+
return await fetchToken();
|
|
347
|
+
if (cached && now() - cached.at < ttl)
|
|
348
|
+
return cached.token;
|
|
349
|
+
inflight ??= fetchToken()
|
|
350
|
+
.then((token) => {
|
|
351
|
+
cached = token ? { token, at: now() } : null;
|
|
352
|
+
return token;
|
|
353
|
+
})
|
|
354
|
+
.finally(() => {
|
|
355
|
+
inflight = null;
|
|
356
|
+
});
|
|
357
|
+
return await inflight;
|
|
358
|
+
},
|
|
359
|
+
seed: (token) => {
|
|
360
|
+
if (isBrowser() && token && !cached)
|
|
361
|
+
cached = { token, at: now() };
|
|
362
|
+
},
|
|
363
|
+
clear: () => {
|
|
364
|
+
cached = null;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Connexion HGDC : bouton « Se connecter avec HGDC », config better-auth et gardes Convex.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/auth#readme",
|