@hgdc/auth 0.7.0 → 0.8.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 +56 -6
- package/dist/events.d.ts +130 -0
- package/dist/events.js +210 -0
- package/dist/react.d.ts +15 -0
- package/dist/react.js +27 -2
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Connexion d'un produit de la famille à Account (OIDC) : bouton « Se connecter
|
|
|
7
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
8
|
| `@hgdc/auth/server` | `hgdcProvider` (plugin `genericOAuth`), `createAuthProxy`, `restoreFetchMode`, `createHgdcEntry` |
|
|
9
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` |
|
|
10
11
|
| `@hgdc/auth/react` | `HgdcSignInButton`, `startHgdcSignIn`, `HgdcMark` (le H tricolore de la famille, `HGDC_MARK_SVG`) |
|
|
11
12
|
|
|
12
13
|
Pairs : better-auth 1.6, convex 1.46+, React 19 (chacun facultatif selon l'import).
|
|
@@ -111,13 +112,18 @@ Le claim `organizations` n'est lu qu'à la création d'une session (étape 1 ci-
|
|
|
111
112
|
- Barre et menu des produits (`@hgdc/ui/products`) : `https://<mot>.hgdc.ch/auth/hgdc?refresh=1&next=/`. Chaque passage d'un produit à l'autre resynchronise.
|
|
112
113
|
- Page d'une organisation inconnue du produit (ou dont la personne n'est pas membre localement) : une seule redirection vers `hgdcRefreshPath("/o/acme")` avant d'afficher « introuvable ». Au retour, `next` porte `?synced=1` : `hasSyncedMarker(location.search)` vrai, la page affiche « introuvable » au lieu de relancer. L'entrée elle-même ne relance jamais un `next` déjà marqué : pas de boucle.
|
|
113
114
|
|
|
114
|
-
```
|
|
115
|
-
|
|
115
|
+
```tsx
|
|
116
|
+
// Côté client, une fois les organisations chargées
|
|
117
|
+
import { useOrganizationRefresh } from "@hgdc/auth/react"
|
|
116
118
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const refreshing = useOrganizationRefresh(viewer !== undefined && !space)
|
|
120
|
+
if (refreshing) return <FamilyLoader page />
|
|
121
|
+
if (!space) return <NotFound />
|
|
122
|
+
|
|
123
|
+
// Dans un loader ou un beforeLoad (rendu serveur compris)
|
|
124
|
+
import { hasSyncedMarker, hgdcRefreshPath } from "@hgdc/auth"
|
|
125
|
+
if (!organization && !hasSyncedMarker(location.searchStr))
|
|
126
|
+
throw redirect({ href: hgdcRefreshPath(location.href), reloadDocument: true })
|
|
121
127
|
```
|
|
122
128
|
|
|
123
129
|
- L'ancienne session n'est pas révoquée : si Account ne répond pas, la personne garde son accès, et la session expire seule (`SESSION_TTL_SECONDS`).
|
|
@@ -188,4 +194,48 @@ load: async (ctx, { organizationId, userId }) => {
|
|
|
188
194
|
|
|
189
195
|
`verifyHgdcIdToken` rend aussi les claims du profil (nom, adresse, photo) pour les recopier. La signature n'est pas revérifiée : le jeton arrive du point de jeton d'Account, reçu en TLS par le serveur (OpenID Connect Core 3.1.3.7, point 6) ; le contrôle de fraîcheur empêche qu'un ancien jeton resté dans la table `account` rejoue des organisations. Une clé d'API vit hors session : `membershipIsFresh(member, now, KEY_MEMBERSHIP_TTL_MS)` lui laisse 30 jours. Les copies périmées se purgent par un cron.
|
|
190
196
|
|
|
197
|
+
## Appliquer les événements d'Account
|
|
198
|
+
|
|
199
|
+
Account pousse les retraits, changements de rôle et suppressions d'organisation à chaque produit, sans attendre la session suivante (ADR 0014). Le produit expose `POST /hgdc/v1/account-events` sur son déploiement Convex :
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// convex/http.ts
|
|
203
|
+
import { ACCOUNT_EVENTS_PATH, handleAccountEventRequest } from "@hgdc/auth/events"
|
|
204
|
+
|
|
205
|
+
http.route({
|
|
206
|
+
path: ACCOUNT_EVENTS_PATH,
|
|
207
|
+
method: "POST",
|
|
208
|
+
handler: httpAction(async (ctx, request) =>
|
|
209
|
+
handleAccountEventRequest(request, {
|
|
210
|
+
secret: process.env.ACCOUNT_EVENTS_SECRET,
|
|
211
|
+
apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// convex/schema.ts : accountEvents: accountEventsTable (de @hgdc/auth/events)
|
|
217
|
+
|
|
218
|
+
// convex/organizations.ts
|
|
219
|
+
export const applyAccountEvent = internalMutation({
|
|
220
|
+
args: { event: vAccountEvent },
|
|
221
|
+
handler: async (ctx, { event }) => {
|
|
222
|
+
const seen = await ctx.db
|
|
223
|
+
.query("accountEvents")
|
|
224
|
+
.withIndex("by_event_id", (q) => q.eq("eventId", event.id))
|
|
225
|
+
.first()
|
|
226
|
+
if (seen) return "duplicate"
|
|
227
|
+
await ctx.db.insert("accountEvents", { eventId: event.id, type: event.type, receivedAt: Date.now() })
|
|
228
|
+
// personne locale : compte better-auth `providerId: "hgdc"`, `accountId: event.userId`
|
|
229
|
+
// member.removed : retirer l'appartenance, sauf si accountEventIsStale(event, membre.syncedAt)
|
|
230
|
+
// member.role_changed : rôle et syncedAt = max(syncedAt, event.occurredAt) ; ne crée jamais d'appartenance
|
|
231
|
+
// organization.deleted : retirer toutes les appartenances, garder les données de l'espace
|
|
232
|
+
return "applied"
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
```
|
|
236
|
+
|
|
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`).
|
|
238
|
+
|
|
239
|
+
Les gardes doivent lire l'appartenance à chaque requête (chargeur de `requireOrgMember`) : c'est ce qui rend le retrait immédiat.
|
|
240
|
+
|
|
191
241
|
Tous droits réservés, Hugo Gebel Développement Conseil.
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type TableDefinition } from "convex/server";
|
|
2
|
+
import { type Validator, type VFloat64, type VObject, type VString } from "convex/values";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type OrgRole } from "./index.ts";
|
|
5
|
+
/** Chemin de l'endpoint de chaque produit, sur son déploiement Convex (`https://<…>.convex.site`). */
|
|
6
|
+
export declare const ACCOUNT_EVENTS_PATH = "/hgdc/v1/account-events";
|
|
7
|
+
/** En-têtes de la requête signée. */
|
|
8
|
+
export declare const ACCOUNT_EVENT_HEADERS: {
|
|
9
|
+
readonly id: "x-hgdc-event-id";
|
|
10
|
+
readonly timestamp: "x-hgdc-timestamp";
|
|
11
|
+
readonly signature: "x-hgdc-signature";
|
|
12
|
+
};
|
|
13
|
+
/** Écart toléré entre l'horodatage signé et l'horloge du produit, en secondes. */
|
|
14
|
+
export declare const ACCOUNT_EVENT_TOLERANCE_SECONDS = 300;
|
|
15
|
+
/** Taille maximale du corps, en octets. Un événement en pèse moins de 400. */
|
|
16
|
+
export declare const ACCOUNT_EVENT_MAX_BYTES: number;
|
|
17
|
+
/** Longueur minimale du secret partagé (32 octets aléatoires en base64url : 43 caractères). */
|
|
18
|
+
export declare const ACCOUNT_EVENT_SECRET_MIN_LENGTH = 32;
|
|
19
|
+
/**
|
|
20
|
+
* Conservation des identifiants d'événements déjà traités chez le produit : 1 jour. Les nouveaux
|
|
21
|
+
* essais d'Account s'étalent sur 81 minutes, et une requête de plus de 5 minutes est refusée.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ACCOUNT_EVENT_RETENTION_MS: number;
|
|
24
|
+
type EventBase = {
|
|
25
|
+
/** Identifiant unique, le même à chaque nouvel essai : clé d'idempotence. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** Instant du changement dans Account, en millisecondes. */
|
|
28
|
+
occurredAt: number;
|
|
29
|
+
/** Organisation d'Account (`id` du claim `organizations`). */
|
|
30
|
+
organizationId: string;
|
|
31
|
+
};
|
|
32
|
+
/** Événement d'organisation poussé par Account. Les personnes sont désignées par leur `sub` Account. */
|
|
33
|
+
export type AccountEvent = (EventBase & {
|
|
34
|
+
type: "member.removed";
|
|
35
|
+
/** Personne retirée : son `sub` Account, l'`accountId` de son compte `hgdc` chez le produit. */
|
|
36
|
+
userId: string;
|
|
37
|
+
/** Retrait par un admin, départ volontaire, ou compte supprimé par HGDC. */
|
|
38
|
+
reason: "removed" | "left" | "user_deleted";
|
|
39
|
+
}) | (EventBase & {
|
|
40
|
+
type: "member.role_changed";
|
|
41
|
+
userId: string;
|
|
42
|
+
/** Nouveau rôle, déjà ramené au plus fort (`highestRole`). */
|
|
43
|
+
role: OrgRole;
|
|
44
|
+
}) | (EventBase & {
|
|
45
|
+
type: "organization.deleted";
|
|
46
|
+
});
|
|
47
|
+
export declare const accountEventSchema: z.ZodType<AccountEvent>;
|
|
48
|
+
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";
|
|
51
|
+
/** Identifiant d'événement neuf (UUID sans tirets), à garder pour tous les essais. */
|
|
52
|
+
export declare function newAccountEventId(): string;
|
|
53
|
+
/**
|
|
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`).
|
|
56
|
+
*/
|
|
57
|
+
export declare function signAccountEvent(event: AccountEvent, options: {
|
|
58
|
+
secret: string;
|
|
59
|
+
now: number;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
body: string;
|
|
62
|
+
headers: Record<string, string>;
|
|
63
|
+
}>;
|
|
64
|
+
/** Pourquoi une requête est refusée. La réponse ne porte que le code, jamais le détail. */
|
|
65
|
+
export type AccountEventRejection = "too_large" | "unauthorized" | "invalid_body";
|
|
66
|
+
export type AccountEventVerification = {
|
|
67
|
+
ok: true;
|
|
68
|
+
event: AccountEvent;
|
|
69
|
+
} | {
|
|
70
|
+
ok: false;
|
|
71
|
+
status: 400 | 401 | 413;
|
|
72
|
+
error: AccountEventRejection;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
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.
|
|
78
|
+
*/
|
|
79
|
+
export declare function verifyAccountEvent(request: Request, options: {
|
|
80
|
+
secret: string;
|
|
81
|
+
now: number;
|
|
82
|
+
}): Promise<AccountEventVerification>;
|
|
83
|
+
/**
|
|
84
|
+
* Endpoint complet d'un produit, à envelopper dans une `httpAction` Convex :
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* export const accountEvents = httpAction(async (ctx, request) =>
|
|
88
|
+
* handleAccountEventRequest(request, {
|
|
89
|
+
* secret: process.env.ACCOUNT_EVENTS_SECRET,
|
|
90
|
+
* apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
|
|
91
|
+
* }),
|
|
92
|
+
* )
|
|
93
|
+
* http.route({ path: ACCOUNT_EVENTS_PATH, method: "POST", handler: accountEvents })
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* `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.
|
|
99
|
+
*/
|
|
100
|
+
export declare function handleAccountEventRequest(request: Request, options: {
|
|
101
|
+
secret: string | undefined;
|
|
102
|
+
apply: (event: AccountEvent) => Promise<AccountEventOutcome>;
|
|
103
|
+
now?: number;
|
|
104
|
+
}): Promise<Response>;
|
|
105
|
+
/**
|
|
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.
|
|
109
|
+
*/
|
|
110
|
+
export declare function accountEventIsStale(event: Pick<AccountEvent, "occurredAt">, syncedAt: number | null | undefined): boolean;
|
|
111
|
+
/** Validateur Convex d'un `AccountEvent` : argument de la mutation interne qui l'applique. */
|
|
112
|
+
export declare const vAccountEvent: Validator<AccountEvent, "required", string>;
|
|
113
|
+
type AccountEventRow = {
|
|
114
|
+
eventId: string;
|
|
115
|
+
type: string;
|
|
116
|
+
receivedAt: number;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Table des événements déjà traités, pour l'idempotence (`accountEvents: accountEventsTable` dans
|
|
120
|
+
* le schéma du produit). Purge après `ACCOUNT_EVENT_RETENTION_MS` par l'index `by_received`.
|
|
121
|
+
*/
|
|
122
|
+
export declare const accountEventsTable: TableDefinition<VObject<AccountEventRow, {
|
|
123
|
+
eventId: VString;
|
|
124
|
+
type: VString;
|
|
125
|
+
receivedAt: VFloat64;
|
|
126
|
+
}>, {
|
|
127
|
+
by_event_id: ["eventId", "_creationTime"];
|
|
128
|
+
by_received: ["receivedAt", "_creationTime"];
|
|
129
|
+
}>;
|
|
130
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ORG_ROLES } from "./index.js";
|
|
5
|
+
/*
|
|
6
|
+
* Événements d'organisation poussés par Account vers chaque produit (ADR 0014 du kit) : un retrait,
|
|
7
|
+
* un changement de rôle ou une organisation supprimée s'appliquent tout de suite, sans attendre la
|
|
8
|
+
* relecture des organisations à la connexion suivante.
|
|
9
|
+
*
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
/** Chemin de l'endpoint de chaque produit, sur son déploiement Convex (`https://<…>.convex.site`). */
|
|
16
|
+
export const ACCOUNT_EVENTS_PATH = "/hgdc/v1/account-events";
|
|
17
|
+
/** En-têtes de la requête signée. */
|
|
18
|
+
export const ACCOUNT_EVENT_HEADERS = {
|
|
19
|
+
id: "x-hgdc-event-id",
|
|
20
|
+
timestamp: "x-hgdc-timestamp",
|
|
21
|
+
signature: "x-hgdc-signature",
|
|
22
|
+
};
|
|
23
|
+
/** Écart toléré entre l'horodatage signé et l'horloge du produit, en secondes. */
|
|
24
|
+
export const ACCOUNT_EVENT_TOLERANCE_SECONDS = 300;
|
|
25
|
+
/** Taille maximale du corps, en octets. Un événement en pèse moins de 400. */
|
|
26
|
+
export const ACCOUNT_EVENT_MAX_BYTES = 8 * 1024;
|
|
27
|
+
/** Longueur minimale du secret partagé (32 octets aléatoires en base64url : 43 caractères). */
|
|
28
|
+
export const ACCOUNT_EVENT_SECRET_MIN_LENGTH = 32;
|
|
29
|
+
/**
|
|
30
|
+
* Conservation des identifiants d'événements déjà traités chez le produit : 1 jour. Les nouveaux
|
|
31
|
+
* essais d'Account s'étalent sur 81 minutes, et une requête de plus de 5 minutes est refusée.
|
|
32
|
+
*/
|
|
33
|
+
export const ACCOUNT_EVENT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
const id = z.string().min(1).max(64);
|
|
35
|
+
const base = {
|
|
36
|
+
id: z.string().regex(/^[A-Za-z0-9_-]{16,64}$/),
|
|
37
|
+
occurredAt: z.number().int().positive(),
|
|
38
|
+
organizationId: id,
|
|
39
|
+
};
|
|
40
|
+
export const accountEventSchema = z.discriminatedUnion("type", [
|
|
41
|
+
z.strictObject({
|
|
42
|
+
...base,
|
|
43
|
+
type: z.literal("member.removed"),
|
|
44
|
+
userId: id,
|
|
45
|
+
reason: z.enum(["removed", "left", "user_deleted"]),
|
|
46
|
+
}),
|
|
47
|
+
z.strictObject({
|
|
48
|
+
...base,
|
|
49
|
+
type: z.literal("member.role_changed"),
|
|
50
|
+
userId: id,
|
|
51
|
+
role: z.enum(ORG_ROLES),
|
|
52
|
+
}),
|
|
53
|
+
z.strictObject({
|
|
54
|
+
...base,
|
|
55
|
+
type: z.literal("organization.deleted"),
|
|
56
|
+
}),
|
|
57
|
+
]);
|
|
58
|
+
const encoder = new TextEncoder();
|
|
59
|
+
async function hmacHex(secret, payload) {
|
|
60
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
61
|
+
"sign",
|
|
62
|
+
]);
|
|
63
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
64
|
+
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
65
|
+
}
|
|
66
|
+
function constantTimeEqual(a, b) {
|
|
67
|
+
if (a.length !== b.length)
|
|
68
|
+
return false;
|
|
69
|
+
let diff = 0;
|
|
70
|
+
for (let i = 0; i < a.length; i++)
|
|
71
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
72
|
+
return diff === 0;
|
|
73
|
+
}
|
|
74
|
+
function assertSecret(secret) {
|
|
75
|
+
if (typeof secret !== "string" || secret.length < ACCOUNT_EVENT_SECRET_MIN_LENGTH)
|
|
76
|
+
throw new Error(`Secret des événements Account absent ou trop court (${ACCOUNT_EVENT_SECRET_MIN_LENGTH} caractères au moins).`);
|
|
77
|
+
}
|
|
78
|
+
/** Identifiant d'événement neuf (UUID sans tirets), à garder pour tous les essais. */
|
|
79
|
+
export function newAccountEventId() {
|
|
80
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
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`).
|
|
85
|
+
*/
|
|
86
|
+
export async function signAccountEvent(event, options) {
|
|
87
|
+
assertSecret(options.secret);
|
|
88
|
+
const parsed = accountEventSchema.parse(event);
|
|
89
|
+
const body = JSON.stringify(parsed);
|
|
90
|
+
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
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const reject = (status, error) => ({
|
|
103
|
+
ok: false,
|
|
104
|
+
status,
|
|
105
|
+
error,
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
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.
|
|
111
|
+
*/
|
|
112
|
+
export async function verifyAccountEvent(request, options) {
|
|
113
|
+
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)
|
|
119
|
+
return reject(413, "too_large");
|
|
120
|
+
const eventId = request.headers.get(ACCOUNT_EVENT_HEADERS.id) ?? "";
|
|
121
|
+
const timestamp = request.headers.get(ACCOUNT_EVENT_HEADERS.timestamp) ?? "";
|
|
122
|
+
const signature = request.headers.get(ACCOUNT_EVENT_HEADERS.signature) ?? "";
|
|
123
|
+
if (!/^[A-Za-z0-9_-]{16,64}$/.test(eventId) || !/^\d{1,12}$/.test(timestamp))
|
|
124
|
+
return reject(401, "unauthorized");
|
|
125
|
+
if (Math.abs(options.now / 1000 - Number(timestamp)) > ACCOUNT_EVENT_TOLERANCE_SECONDS)
|
|
126
|
+
return reject(401, "unauthorized");
|
|
127
|
+
const expected = `v1=${await hmacHex(options.secret, `${eventId}.${timestamp}.${raw}`)}`;
|
|
128
|
+
if (!constantTimeEqual(signature, expected))
|
|
129
|
+
return reject(401, "unauthorized");
|
|
130
|
+
let json;
|
|
131
|
+
try {
|
|
132
|
+
json = JSON.parse(raw);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return reject(400, "invalid_body");
|
|
136
|
+
}
|
|
137
|
+
const parsed = accountEventSchema.safeParse(json);
|
|
138
|
+
if (!parsed.success || parsed.data.id !== eventId)
|
|
139
|
+
return reject(400, "invalid_body");
|
|
140
|
+
return { ok: true, event: parsed.data };
|
|
141
|
+
}
|
|
142
|
+
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
143
|
+
/**
|
|
144
|
+
* Endpoint complet d'un produit, à envelopper dans une `httpAction` Convex :
|
|
145
|
+
*
|
|
146
|
+
* ```ts
|
|
147
|
+
* export const accountEvents = httpAction(async (ctx, request) =>
|
|
148
|
+
* handleAccountEventRequest(request, {
|
|
149
|
+
* secret: process.env.ACCOUNT_EVENTS_SECRET,
|
|
150
|
+
* apply: (event) => ctx.runMutation(internal.organizations.applyAccountEvent, { event }),
|
|
151
|
+
* }),
|
|
152
|
+
* )
|
|
153
|
+
* http.route({ path: ACCOUNT_EVENTS_PATH, method: "POST", handler: accountEvents })
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* `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.
|
|
159
|
+
*/
|
|
160
|
+
export async function handleAccountEventRequest(request, options) {
|
|
161
|
+
const { secret } = options;
|
|
162
|
+
if (typeof secret !== "string" || secret.length < ACCOUNT_EVENT_SECRET_MIN_LENGTH)
|
|
163
|
+
return json(503, { error: "not_configured" });
|
|
164
|
+
const verified = await verifyAccountEvent(request, { secret, now: options.now ?? Date.now() });
|
|
165
|
+
if (!verified.ok)
|
|
166
|
+
return json(verified.status, { error: verified.error });
|
|
167
|
+
const outcome = await options.apply(verified.event);
|
|
168
|
+
return json(200, { status: outcome });
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
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.
|
|
174
|
+
*/
|
|
175
|
+
export function accountEventIsStale(event, syncedAt) {
|
|
176
|
+
return typeof syncedAt === "number" && syncedAt > event.occurredAt;
|
|
177
|
+
}
|
|
178
|
+
const vRole = v.union(...ORG_ROLES.map((role) => v.literal(role)));
|
|
179
|
+
/** Validateur Convex d'un `AccountEvent` : argument de la mutation interne qui l'applique. */
|
|
180
|
+
export const vAccountEvent = v.union(v.object({
|
|
181
|
+
id: v.string(),
|
|
182
|
+
type: v.literal("member.removed"),
|
|
183
|
+
occurredAt: v.number(),
|
|
184
|
+
organizationId: v.string(),
|
|
185
|
+
userId: v.string(),
|
|
186
|
+
reason: v.union(v.literal("removed"), v.literal("left"), v.literal("user_deleted")),
|
|
187
|
+
}), v.object({
|
|
188
|
+
id: v.string(),
|
|
189
|
+
type: v.literal("member.role_changed"),
|
|
190
|
+
occurredAt: v.number(),
|
|
191
|
+
organizationId: v.string(),
|
|
192
|
+
userId: v.string(),
|
|
193
|
+
role: vRole,
|
|
194
|
+
}), v.object({
|
|
195
|
+
id: v.string(),
|
|
196
|
+
type: v.literal("organization.deleted"),
|
|
197
|
+
occurredAt: v.number(),
|
|
198
|
+
organizationId: v.string(),
|
|
199
|
+
}));
|
|
200
|
+
/**
|
|
201
|
+
* Table des événements déjà traités, pour l'idempotence (`accountEvents: accountEventsTable` dans
|
|
202
|
+
* le schéma du produit). Purge après `ACCOUNT_EVENT_RETENTION_MS` par l'index `by_received`.
|
|
203
|
+
*/
|
|
204
|
+
export const accountEventsTable = defineTable({
|
|
205
|
+
eventId: v.string(),
|
|
206
|
+
type: v.string(),
|
|
207
|
+
receivedAt: v.number(),
|
|
208
|
+
})
|
|
209
|
+
.index("by_event_id", ["eventId"])
|
|
210
|
+
.index("by_received", ["receivedAt"]);
|
package/dist/react.d.ts
CHANGED
|
@@ -52,3 +52,18 @@ export declare function HgdcMark({ className }: {
|
|
|
52
52
|
* `className`, l'état d'attente est exposé par `aria-busy` et `data-pending`.
|
|
53
53
|
*/
|
|
54
54
|
export declare function HgdcSignInButton({ authClient, callbackURL, errorCallbackURL, onError, label, disabled, ...rest }: HgdcSignInButtonProps): ReactElement;
|
|
55
|
+
/**
|
|
56
|
+
* Page d'une organisation inconnue du produit, ou dont la personne n'est pas membre dans sa copie :
|
|
57
|
+
* une invitation acceptée dans Account n'y arrive qu'à la session suivante (ADR 0013). Quand
|
|
58
|
+
* `unknown` est vrai, part une fois vers `/auth/hgdc?refresh=1&next=<page>` et rend `true` (afficher
|
|
59
|
+
* un chargement). Au retour (`?synced=1`), rend `false` : afficher « introuvable ».
|
|
60
|
+
*
|
|
61
|
+
* Côté client seulement : à appeler une fois la liste des organisations chargée.
|
|
62
|
+
*
|
|
63
|
+
* ```tsx
|
|
64
|
+
* const refreshing = useOrganizationRefresh(viewer !== undefined && !space)
|
|
65
|
+
* if (refreshing) return <FamilyLoader page />
|
|
66
|
+
* if (!space) return <NotFound />
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function useOrganizationRefresh(unknown: boolean): boolean;
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
import { HGDC_PROVIDER_ID } from "./index.js";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { HGDC_PROVIDER_ID, hasSyncedMarker, hgdcRefreshPath } from "./index.js";
|
|
4
4
|
/**
|
|
5
5
|
* Part vers HGDC Account. Renvoie `{ ok: true }` quand la redirection est lancée,
|
|
6
6
|
* sinon l'erreur du client ou du réseau, sans lever.
|
|
@@ -50,3 +50,28 @@ export function HgdcSignInButton({ authClient, callbackURL = "/", errorCallbackU
|
|
|
50
50
|
}
|
|
51
51
|
return (_jsxs("button", { ...rest, type: "button", disabled: disabled === true || pending, "aria-busy": pending, "data-pending": pending ? "" : undefined, onClick: () => void signIn(), children: [_jsx(HgdcMark, {}), _jsx("span", { children: pending ? "Redirection vers Account…" : label })] }));
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Page d'une organisation inconnue du produit, ou dont la personne n'est pas membre dans sa copie :
|
|
55
|
+
* une invitation acceptée dans Account n'y arrive qu'à la session suivante (ADR 0013). Quand
|
|
56
|
+
* `unknown` est vrai, part une fois vers `/auth/hgdc?refresh=1&next=<page>` et rend `true` (afficher
|
|
57
|
+
* un chargement). Au retour (`?synced=1`), rend `false` : afficher « introuvable ».
|
|
58
|
+
*
|
|
59
|
+
* Côté client seulement : à appeler une fois la liste des organisations chargée.
|
|
60
|
+
*
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const refreshing = useOrganizationRefresh(viewer !== undefined && !space)
|
|
63
|
+
* if (refreshing) return <FamilyLoader page />
|
|
64
|
+
* if (!space) return <NotFound />
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function useOrganizationRefresh(unknown) {
|
|
68
|
+
const synced = typeof window === "undefined" || hasSyncedMarker(window.location.search);
|
|
69
|
+
const refreshing = unknown && !synced;
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!refreshing)
|
|
72
|
+
return;
|
|
73
|
+
const { pathname, search, hash } = window.location;
|
|
74
|
+
window.location.replace(hgdcRefreshPath(`${pathname}${search}${hash}`));
|
|
75
|
+
}, [refreshing]);
|
|
76
|
+
return refreshing;
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"types": "./dist/convex.d.ts",
|
|
28
28
|
"default": "./dist/convex.js"
|
|
29
29
|
},
|
|
30
|
+
"./events": {
|
|
31
|
+
"types": "./dist/events.d.ts",
|
|
32
|
+
"default": "./dist/events.js"
|
|
33
|
+
},
|
|
30
34
|
"./server": {
|
|
31
35
|
"types": "./dist/server.d.ts",
|
|
32
36
|
"default": "./dist/server.js"
|