@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 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() partout
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 ou un corps altéré rendent 401 ; un identifiant déjà traité, `duplicate`. Les identifiants se purgent après `ACCOUNT_EVENT_RETENTION_MS` (cron, index `by_received`).
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
- /** Ce que le produit a fait de l'événement : rendu dans la réponse, pour les journaux d'Account. */
50
- export type AccountEventOutcome = "applied" | "ignored" | "duplicate";
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. À rappeler à chaque essai (horodatage neuf),
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 : vérifie taille, horodatage (±5 minutes), signature en temps constant, puis le
76
- * corps (`accountEventSchema`) et l'accord entre l'identifiant signé et celui du corps. Ne lit le
77
- * corps qu'une fois. Ne dit rien du rejeu : à écarter par la table des événements traités.
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. Sans secret posé : 503, Account réessaie plus tard.
98
- * Une erreur de `apply` remonte (500) : Account réessaie aussi.
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 (`syncedAt` plus récent) :
107
- * un essai en retard ne doit pas défaire une connexion plus récente (personne retirée puis
108
- * réinvitée, rôle changé deux fois). Une copie sans `syncedAt` n'est jamais plus récente.
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
- * `v1=` hex de HMAC(secret, "<id>.<horodatage>.<corps brut>"). L'identifiant de l'événement est
12
- * dans le MAC : rejouer une requête captée dans la fenêtre de 5 minutes ne passe pas la table des
13
- * événements déjà traités du produit.
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. À rappeler à chaque essai (horodatage neuf),
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 signature = `v1=${await hmacHex(options.secret, `${parsed.id}.${timestamp}.${body}`)}`;
92
- return {
93
- body,
94
- headers: {
95
- "content-type": "application/json",
96
- [ACCOUNT_EVENT_HEADERS.id]: parsed.id,
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 : vérifie taille, horodatage (±5 minutes), signature en temps constant, puis le
109
- * corps (`accountEventSchema`) et l'accord entre l'identifiant signé et celui du corps. Ne lit le
110
- * corps qu'une fois. Ne dit rien du rejeu : à écarter par la table des événements traités.
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
- const declared = Number(request.headers.get("content-length") ?? "0");
115
- if (declared > ACCOUNT_EVENT_MAX_BYTES)
116
- return reject(413, "too_large");
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 signature = request.headers.get(ACCOUNT_EVENT_HEADERS.signature) ?? "";
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 = `v1=${await hmacHex(options.secret, `${eventId}.${timestamp}.${raw}`)}`;
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. Sans secret posé : 503, Account réessaie plus tard.
158
- * Une erreur de `apply` remonte (500) : Account réessaie aussi.
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, { secret, now: options.now ?? Date.now() });
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 outcome = await options.apply(verified.event);
168
- return json(200, { status: outcome });
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 (`syncedAt` plus récent) :
172
- * un essai en retard ne doit pas défaire une connexion plus récente (personne retirée puis
173
- * réinvitée, rôle changé deux fois). Une copie sans `syncedAt` n'est jamais plus récente.
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.8.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",