@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 +7 -2
- package/dist/events.d.ts +65 -12
- package/dist/events.js +153 -32
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -1
- package/package.json +1 -1
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()
|
|
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
|
|
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
|
-
/**
|
|
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
|
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.
|
|
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",
|