@hgdc/auth 0.8.0 → 0.9.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
@@ -183,7 +183,7 @@ const claimed = verifiedOrganizationsFromIdToken(hgdc.idToken ?? "", {
183
183
  now: Date.now(),
184
184
  }) // lève HgdcIdTokenError : émetteur, audience (clientId), iat de plus de 10 minutes
185
185
  const plan = planOrganizationSync(claimed, membershipsOfThisUser)
186
- // mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = Date.now() partout
186
+ // mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = issuedAt partout (iat du jeton, verifyHgdcIdToken), jamais Date.now()
187
187
 
188
188
  // chargeur de requireOrgMember
189
189
  load: async (ctx, { organizationId, userId }) => {
@@ -207,6 +207,7 @@ http.route({
207
207
  method: "POST",
208
208
  handler: httpAction(async (ctx, request) =>
209
209
  handleAccountEventRequest(request, {
210
+ audience: "host", // le produit : signé par Account, vérifié ici
210
211
  secret: process.env.ACCOUNT_EVENTS_SECRET,
211
212
  apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
212
213
  }),
@@ -218,6 +219,7 @@ http.route({
218
219
  // convex/organizations.ts
219
220
  export const applyAccountEvent = internalMutation({
220
221
  args: { event: vAccountEvent },
222
+ returns: vAccountEventOutcome, // "applied" | "duplicate" | "ignored:<raison>"
221
223
  handler: async (ctx, { event }) => {
222
224
  const seen = await ctx.db
223
225
  .query("accountEvents")
@@ -229,12 +231,15 @@ export const applyAccountEvent = internalMutation({
229
231
  // member.removed : retirer l'appartenance, sauf si accountEventIsStale(event, membre.syncedAt)
230
232
  // member.role_changed : rôle et syncedAt = max(syncedAt, event.occurredAt) ; ne crée jamais d'appartenance
231
233
  // organization.deleted : retirer toutes les appartenances, garder les données de l'espace
234
+ // rien à appliquer : "ignored:unknown_organization" | "ignored:unknown_user" | "ignored:not_member" | "ignored:stale"
232
235
  return "applied"
233
236
  },
234
237
  })
235
238
  ```
236
239
 
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`).
240
+ 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`).
241
+
242
+ 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
243
 
239
244
  Les gardes doivent lire l'appartenance à chaque requête (chargeur de `requireOrgMember`) : c'est ce qui rend le retrait immédiat.
240
245
 
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
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/auth",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",