@hgdc/auth 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  Connexion d'un produit HGDC à HGDC Account (OIDC) : bouton « Se connecter avec HGDC », configuration du client better-auth, proxy `/api/auth` pour TanStack Start et gardes Convex.
4
4
 
5
- | Import | Contenu |
6
- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
- | `@hgdc/auth` | `HGDC_ISSUER`, `HGDC_PROVIDER_ID`, `HGDC_SCOPES`, rôles (`highestRole`, `hasRole`), `parseHgdcProfile`, `organizationsFromIdToken`, `planOrganizationSync` |
8
- | `@hgdc/auth/server` | `hgdcProvider` (plugin `genericOAuth`), `createAuthProxy`, `restoreFetchMode` |
9
- | `@hgdc/auth/convex` | `requireIdentity`, `requireOrgMember`, `isAuthError` |
10
- | `@hgdc/auth/react` | `HgdcSignInButton`, `startHgdcSignIn`, `HgdcMark` |
5
+ | Import | Contenu |
6
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `@hgdc/auth` | `HGDC_ISSUER`, `HGDC_PROVIDER_ID`, `HGDC_SCOPES`, rôles (`highestRole`, `hasRole`), `parseHgdcProfile`, `verifiedOrganizationsFromIdToken`, `verifyHgdcIdToken`, `planOrganizationSync`, fraîcheur (`membershipIsFresh`, `SESSION_TTL_SECONDS`, `MEMBERSHIP_TTL_MS`, `KEY_MEMBERSHIP_TTL_MS`) |
8
+ | `@hgdc/auth/server` | `hgdcProvider` (plugin `genericOAuth`), `createAuthProxy`, `restoreFetchMode` |
9
+ | `@hgdc/auth/convex` | `requireIdentity`, `requireOrgMember`, `isAuthError`, `authErrorReason`, `AUTH_ERROR_MESSAGES` |
10
+ | `@hgdc/auth/react` | `HgdcSignInButton`, `startHgdcSignIn`, `HgdcMark` |
11
11
 
12
12
  Pairs : better-auth 1.6, convex 1.46+, React 19 (chacun facultatif selon l'import).
13
13
 
@@ -67,25 +67,55 @@ const { userId, role } = await requireOrgMember(ctx, organizationId, {
67
67
  })
68
68
  ```
69
69
 
70
- À appeler avant toute lecture ou écriture. L'émetteur est obligatoire : un `subject` n'est unique que chez son émetteur, et sans ce contrôle un autre fournisseur accepté par Convex pourrait se faire passer pour un membre. Sans session ou d'un autre émetteur : `ConvexError({ code: "UNAUTHENTICATED" })`. Personne étrangère à l'organisation ou rôle insuffisant : `FORBIDDEN`. Une appartenance rendue pour une autre personne ou une autre organisation est refusée.
70
+ À appeler avant toute lecture ou écriture. L'émetteur est obligatoire : un `subject` n'est unique que chez son émetteur, et sans ce contrôle un autre fournisseur accepté par Convex pourrait se faire passer pour un membre. Une appartenance rendue pour une autre personne ou une autre organisation est refusée.
71
+
72
+ Un refus est une `ConvexError({ code, reason, message })`. `message` s'affiche tel quel (tutoiement) ; pour décider quoi montrer, lire `reason`, jamais le texte :
73
+
74
+ | `reason` | `code` | Cas |
75
+ | ------------------- | ----------------- | ----------------------------------------- |
76
+ | `no_session` | `UNAUTHENTICATED` | Personne n'est connecté |
77
+ | `wrong_issuer` | `UNAUTHENTICATED` | Jeton d'un autre émetteur |
78
+ | `not_member` | `FORBIDDEN` | La personne n'est pas dans l'organisation |
79
+ | `insufficient_role` | `FORBIDDEN` | Elle en est membre, rôle insuffisant |
80
+
81
+ ```ts
82
+ import { authErrorReason } from "@hgdc/auth/convex"
83
+
84
+ if (authErrorReason(error) === "insufficient_role") showUpgradeHint()
85
+ ```
86
+
87
+ `authErrorReason` marche des deux côtés (la `ConvexError` reconstruite par le client garde ses données) et déduit la raison d'une erreur d'avant 0.4 par son code.
71
88
 
72
89
  Les organisations arrivent par la portée `organizations` : claim `organizations: [{ id, slug, name, role }]` de l'id_token et de userinfo.
73
90
 
74
91
  ## Synchroniser les organisations
75
92
 
76
- Les organisations vivent dans Account ; le produit en garde une copie (espace par organisation, appartenances) que lit le chargeur de `requireOrgMember`. Elle se met à jour à chaque connexion :
93
+ Les organisations vivent dans Account ; le produit en garde une copie (espace par organisation, appartenances) que lit le chargeur de `requireOrgMember`. Le modèle recommandé, celui de Host :
77
94
 
78
- ```ts
79
- import { organizationsFromIdToken, planOrganizationSync } from "@hgdc/auth"
95
+ 1. **Synchroniser dans `databaseHooks.session.create.before`**, avant que la session existe. Un id_token refusé fait échouer la connexion plutôt que d'ouvrir une session sur des droits périmés.
96
+ 2. **Sessions de 7 jours sans prolongation** : `session: { expiresIn: SESSION_TTL_SECONDS, disableSessionRefresh: true }`. Les organisations sont donc relues au moins une fois par semaine.
97
+ 3. **Appartenance périmée, pas d'accès** : chaque appartenance garde `syncedAt` (remis à jour à chaque connexion, même sans changement), et le chargeur rend `null` quand `membershipIsFresh` est faux. Une personne retirée dans Account perd l'accès au plus tard 8 jours après (`MEMBERSHIP_TTL_MS`).
80
98
 
81
- // better-auth : databaseHooks.session.create.after → mutation interne du produit
82
- const account = /* ligne `account` de la personne avec providerId "hgdc" */
83
- const claimed = organizationsFromIdToken(account.idToken) // jeton reçu du point de jeton, déjà validé
99
+ ```ts
100
+ import { HGDC_ISSUER, membershipIsFresh, planOrganizationSync, verifiedOrganizationsFromIdToken } from "@hgdc/auth"
101
+
102
+ // better-auth : databaseHooks.session.create.before
103
+ const hgdc = accounts.find((a) => a.providerId === "hgdc") // exactement un, sinon refuser
104
+ const claimed = verifiedOrganizationsFromIdToken(hgdc.idToken ?? "", {
105
+ issuer: HGDC_ISSUER,
106
+ clientId: process.env.HGDC_CLIENT_ID!,
107
+ now: Date.now(),
108
+ }) // lève HgdcIdTokenError : émetteur, audience (clientId), iat de plus de 10 minutes
84
109
  const plan = planOrganizationSync(claimed, membershipsOfThisUser)
85
- // plan.upsert : créer l'espace (index unique sur organizationId) et l'appartenance, ou mettre à jour rôle, nom, slug
86
- // plan.remove : retirer les appartenances des organisations quittées
110
+ // mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = Date.now() partout
111
+
112
+ // chargeur de requireOrgMember
113
+ load: async (ctx, { organizationId, userId }) => {
114
+ const member = /* appartenance lue dans la base du produit */
115
+ return member && membershipIsFresh(member, Date.now()) ? member : null
116
+ }
87
117
  ```
88
118
 
89
- Un rôle changé dans Account n'arrive qu'à la connexion suivante : garder des sessions courtes (7 jours au plus).
119
+ `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.
90
120
 
91
121
  Tous droits réservés, Hugo Gebel Développement Conseil.
package/dist/convex.d.ts CHANGED
@@ -2,9 +2,21 @@ import type { Auth, UserIdentity } from "convex/server";
2
2
  import { ConvexError } from "convex/values";
3
3
  import { type OrgRole } from "./index.ts";
4
4
  export type AuthErrorCode = "UNAUTHENTICATED" | "FORBIDDEN";
5
+ /**
6
+ * Raison précise d'un refus, stable d'une version à l'autre : c'est elle qu'un produit lit pour
7
+ * choisir quoi afficher, jamais le texte de `message`.
8
+ * - `no_session` : personne n'est connecté (`UNAUTHENTICATED`) ;
9
+ * - `wrong_issuer` : jeton d'un autre émetteur que celui attendu (`UNAUTHENTICATED`) ;
10
+ * - `not_member` : la personne n'appartient pas à l'organisation (`FORBIDDEN`) ;
11
+ * - `insufficient_role` : elle en est membre, mais son rôle ne suffit pas (`FORBIDDEN`).
12
+ */
13
+ export type AuthErrorReason = "no_session" | "wrong_issuer" | "not_member" | "insufficient_role";
14
+ /** Messages des gardes, au tutoiement : ils s'affichent tels quels aux personnes. */
15
+ export declare const AUTH_ERROR_MESSAGES: Readonly<Record<AuthErrorReason, string>>;
5
16
  /** Données d'une `ConvexError` levée par les gardes : `message` est affichable tel quel. */
6
17
  export type AuthErrorData = {
7
18
  code: AuthErrorCode;
19
+ reason: AuthErrorReason;
8
20
  message: string;
9
21
  };
10
22
  /** Lien entre une personne et une organisation, tel que le produit le stocke. */
@@ -30,6 +42,12 @@ export type OrgAccess = {
30
42
  };
31
43
  /** Vrai si `error` vient d'une garde de ce module. */
32
44
  export declare function isAuthError(error: unknown): error is ConvexError<AuthErrorData>;
45
+ /**
46
+ * Raison d'un refus des gardes, ou `null` si `error` n'en vient pas. Côté client, l'erreur
47
+ * arrive sérialisée (`ConvexError` reconstruite par le client Convex) : la lecture vaut des deux
48
+ * côtés. Une erreur d'une version d'avant `reason` (0.3) est déduite de son code.
49
+ */
50
+ export declare function authErrorReason(error: unknown): AuthErrorReason | null;
33
51
  /**
34
52
  * Émetteur des jetons dont le `subject` désigne un utilisateur du produit. Avec le plugin
35
53
  * `convex` de better-auth : `process.env.CONVEX_SITE_URL`. Un `subject` n'est unique que chez
@@ -50,7 +68,7 @@ export declare function requireIdentity(ctx: {
50
68
  * et au moins le rôle `minRole` (par défaut `member`).
51
69
  *
52
70
  * Une personne étrangère à l'organisation et un membre au rôle insuffisant reçoivent
53
- * tous deux `FORBIDDEN`, avec des messages distincts.
71
+ * tous deux `FORBIDDEN`, distingués par `reason` (`not_member`, `insufficient_role`).
54
72
  */
55
73
  export declare function requireOrgMember<Ctx extends {
56
74
  auth: Auth;
package/dist/convex.js CHANGED
@@ -1,7 +1,20 @@
1
1
  import { ConvexError } from "convex/values";
2
2
  import { highestRole, ORG_ROLES } from "./index.js";
3
- function authError(code, message) {
4
- return new ConvexError({ code, message });
3
+ /** Messages des gardes, au tutoiement : ils s'affichent tels quels aux personnes. */
4
+ export const AUTH_ERROR_MESSAGES = {
5
+ no_session: "Connecte-toi pour continuer.",
6
+ wrong_issuer: "Connecte-toi pour continuer.",
7
+ not_member: "Tu n'as pas accès à cette organisation.",
8
+ insufficient_role: "Ton rôle dans cette organisation ne permet pas cette action.",
9
+ };
10
+ const CODE_OF = {
11
+ no_session: "UNAUTHENTICATED",
12
+ wrong_issuer: "UNAUTHENTICATED",
13
+ not_member: "FORBIDDEN",
14
+ insufficient_role: "FORBIDDEN",
15
+ };
16
+ function authError(reason) {
17
+ return new ConvexError({ code: CODE_OF[reason], reason, message: AUTH_ERROR_MESSAGES[reason] });
5
18
  }
6
19
  /** Vrai si `error` vient d'une garde de ce module. */
7
20
  export function isAuthError(error) {
@@ -13,6 +26,22 @@ export function isAuthError(error) {
13
26
  "code" in data &&
14
27
  (data.code === "UNAUTHENTICATED" || data.code === "FORBIDDEN"));
15
28
  }
29
+ function isReason(value) {
30
+ return typeof value === "string" && Object.hasOwn(CODE_OF, value);
31
+ }
32
+ /**
33
+ * Raison d'un refus des gardes, ou `null` si `error` n'en vient pas. Côté client, l'erreur
34
+ * arrive sérialisée (`ConvexError` reconstruite par le client Convex) : la lecture vaut des deux
35
+ * côtés. Une erreur d'une version d'avant `reason` (0.3) est déduite de son code.
36
+ */
37
+ export function authErrorReason(error) {
38
+ if (!isAuthError(error))
39
+ return null;
40
+ const data = error.data;
41
+ if (typeof data === "object" && data !== null && "reason" in data && isReason(data.reason))
42
+ return data.reason;
43
+ return error.data.code === "UNAUTHENTICATED" ? "no_session" : "not_member";
44
+ }
16
45
  function normalizeIssuer(value) {
17
46
  return value.replace(/\/+$/, "");
18
47
  }
@@ -20,11 +49,11 @@ function normalizeIssuer(value) {
20
49
  export async function requireIdentity(ctx, options) {
21
50
  const identity = await ctx.auth.getUserIdentity();
22
51
  if (!identity)
23
- throw authError("UNAUTHENTICATED", "Connectez-vous pour continuer.");
52
+ throw authError("no_session");
24
53
  if (!options.issuer)
25
54
  throw new Error("requireIdentity : émetteur attendu manquant (CONVEX_SITE_URL ?).");
26
55
  if (normalizeIssuer(identity.issuer) !== normalizeIssuer(options.issuer))
27
- throw authError("UNAUTHENTICATED", "Connectez-vous pour continuer.");
56
+ throw authError("wrong_issuer");
28
57
  return identity;
29
58
  }
30
59
  /**
@@ -33,7 +62,7 @@ export async function requireIdentity(ctx, options) {
33
62
  * et au moins le rôle `minRole` (par défaut `member`).
34
63
  *
35
64
  * Une personne étrangère à l'organisation et un membre au rôle insuffisant reçoivent
36
- * tous deux `FORBIDDEN`, avec des messages distincts.
65
+ * tous deux `FORBIDDEN`, distingués par `reason` (`not_member`, `insufficient_role`).
37
66
  */
38
67
  export async function requireOrgMember(ctx, organizationId, options) {
39
68
  const identity = await requireIdentity(ctx, options);
@@ -41,12 +70,12 @@ export async function requireOrgMember(ctx, organizationId, options) {
41
70
  const membership = await options.load(ctx, { organizationId, userId });
42
71
  // Une copie mal synchronisée ne doit jamais ouvrir l'accès à la mauvaise personne.
43
72
  if (!membership || membership.userId !== userId || membership.organizationId !== organizationId) {
44
- throw authError("FORBIDDEN", "Vous n'avez pas accès à cette organisation.");
73
+ throw authError("not_member");
45
74
  }
46
75
  const role = highestRole(membership.role);
47
76
  const minRole = options.minRole ?? "member";
48
77
  if (role === null || ORG_ROLES.indexOf(role) < ORG_ROLES.indexOf(minRole)) {
49
- throw authError("FORBIDDEN", "Votre rôle dans cette organisation ne permet pas cette action.");
78
+ throw authError("insufficient_role");
50
79
  }
51
80
  return { identity, userId, membership, role };
52
81
  }
package/dist/index.d.ts CHANGED
@@ -43,6 +43,8 @@ export declare function parseHgdcProfile(claims: unknown): HgdcProfile;
43
43
  * du point de jeton d'Account par le serveur (champ `idToken` de la table `account` de
44
44
  * better-auth) : sa signature n'est pas revérifiée ici (OpenID Connect Core 3.1.3.7, point 6).
45
45
  * Lève une erreur si le jeton est illisible ou si un claim est invalide ; `[]` sans la portée.
46
+ * À la connexion, préférer `verifiedOrganizationsFromIdToken`, qui contrôle aussi émetteur,
47
+ * audience et fraîcheur.
46
48
  */
47
49
  export declare function organizationsFromIdToken(idToken: string): HgdcOrganization[];
48
50
  /** Appartenance d'une personne telle qu'un produit l'a copiée depuis Account. */
@@ -63,3 +65,76 @@ export type OrganizationSyncPlan = {
63
65
  * Ne renvoie que ce qui change : une connexion sans changement n'écrit rien.
64
66
  */
65
67
  export declare function planOrganizationSync(claimed: readonly HgdcOrganization[], current: readonly SyncedMembership[]): OrganizationSyncPlan;
68
+ /** Âge maximal d'un id_token lu à la connexion : il vient d'être reçu du point de jeton d'Account. */
69
+ export declare const ID_TOKEN_MAX_AGE_SECONDS: number;
70
+ /** Avance d'horloge tolérée entre Account et le produit, en secondes. */
71
+ export declare const ID_TOKEN_CLOCK_SKEW_SECONDS: number;
72
+ /** Pourquoi un id_token est refusé, pour les journaux : la personne ne voit que « Connexion HGDC requise ». */
73
+ export type IdTokenRejection = "unreadable" | "wrong_issuer" | "wrong_audience" | "stale" | "invalid_claims";
74
+ /** Erreur de `verifyHgdcIdToken` et `verifiedOrganizationsFromIdToken`. */
75
+ export declare class HgdcIdTokenError extends Error {
76
+ readonly reason: IdTokenRejection;
77
+ constructor(reason: IdTokenRejection, message: string);
78
+ }
79
+ export type IdTokenExpectation = {
80
+ /** Émetteur attendu : `HGDC_ISSUER`, ou celui d'un Account de préproduction. */
81
+ issuer: string;
82
+ /** Identifiant du client du produit : l'audience du jeton. */
83
+ clientId: string;
84
+ /** Instant présent en millisecondes (`Date.now()`). */
85
+ now: number;
86
+ };
87
+ /**
88
+ * Contrôle de provenance et de fraîcheur d'un id_token lu dans la table `account` de better-auth,
89
+ * avant d'en recopier quoi que ce soit (OpenID Connect Core 3.1.3.7) :
90
+ * - `iss` égal à l'émetteur attendu ;
91
+ * - `aud` contient `clientId`, et `azp` vaut `clientId` quand il y a plusieurs audiences ;
92
+ * - `iat` de moins de `ID_TOKEN_MAX_AGE_SECONDS`, pas dans le futur, et `exp` non dépassé.
93
+ *
94
+ * La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
95
+ * serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
96
+ * better-auth n'aurait pas réécrit, rejoue des organisations périmées.
97
+ */
98
+ export declare function verifyHgdcIdToken(idToken: string, expected: IdTokenExpectation): {
99
+ claims: Record<string, unknown>;
100
+ organizations: HgdcOrganization[];
101
+ };
102
+ /**
103
+ * Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
104
+ * `databaseHooks.session.create.before` : un jeton refusé fait échouer la connexion plutôt que
105
+ * d'ouvrir une session sur des droits périmés.
106
+ *
107
+ * ```ts
108
+ * const organizations = verifiedOrganizationsFromIdToken(account.idToken, {
109
+ * issuer: HGDC_ISSUER,
110
+ * clientId: process.env.HGDC_CLIENT_ID!,
111
+ * now: Date.now(),
112
+ * })
113
+ * ```
114
+ */
115
+ export declare function verifiedOrganizationsFromIdToken(idToken: string, expected: IdTokenExpectation): HgdcOrganization[];
116
+ /**
117
+ * Durée d'une session d'un produit, sans prolongation (`session: { expiresIn: SESSION_TTL_SECONDS,
118
+ * disableSessionRefresh: true }`) : les organisations sont relues à chaque connexion, donc au
119
+ * moins une fois tous les 7 jours.
120
+ */
121
+ export declare const SESSION_TTL_SECONDS: number;
122
+ /**
123
+ * Âge maximal d'une appartenance copiée depuis Account, en millisecondes : 8 jours, soit une
124
+ * session de 7 jours et un jour de marge. Une copie plus ancienne n'est plus portée par aucune
125
+ * session (personne retirée dans Account, session d'avant la synchronisation) : elle n'ouvre plus rien.
126
+ */
127
+ export declare const MEMBERSHIP_TTL_MS: number;
128
+ /**
129
+ * Âge maximal de l'appartenance de la créatrice d'une clé d'API, qui vit hors session (CLI,
130
+ * intégrations) : 30 jours. Passé ce délai sans reconnexion, la clé cesse d'agir.
131
+ */
132
+ export declare const KEY_MEMBERSHIP_TTL_MS: number;
133
+ /**
134
+ * Vrai si l'appartenance a été confirmée par Account il y a moins de `ttl` (par défaut
135
+ * `MEMBERSHIP_TTL_MS`). `syncedAt` : dernière synchronisation réussie, en millisecondes, mise
136
+ * à jour même quand rien n'a changé. Une copie sans date n'est jamais fraîche.
137
+ */
138
+ export declare function membershipIsFresh(membership: {
139
+ syncedAt?: number | null | undefined;
140
+ }, now: number, ttl?: number): boolean;
package/dist/index.js CHANGED
@@ -70,6 +70,8 @@ function decodeBase64Url(segment) {
70
70
  * du point de jeton d'Account par le serveur (champ `idToken` de la table `account` de
71
71
  * better-auth) : sa signature n'est pas revérifiée ici (OpenID Connect Core 3.1.3.7, point 6).
72
72
  * Lève une erreur si le jeton est illisible ou si un claim est invalide ; `[]` sans la portée.
73
+ * À la connexion, préférer `verifiedOrganizationsFromIdToken`, qui contrôle aussi émetteur,
74
+ * audience et fraîcheur.
73
75
  */
74
76
  export function organizationsFromIdToken(idToken) {
75
77
  const payload = idToken.split(".")[1];
@@ -95,3 +97,107 @@ export function planOrganizationSync(claimed, current) {
95
97
  remove: current.filter((m) => !claimedIds.has(m.organizationId)).map((m) => m.organizationId),
96
98
  };
97
99
  }
100
+ /** Âge maximal d'un id_token lu à la connexion : il vient d'être reçu du point de jeton d'Account. */
101
+ export const ID_TOKEN_MAX_AGE_SECONDS = 10 * 60;
102
+ /** Avance d'horloge tolérée entre Account et le produit, en secondes. */
103
+ export const ID_TOKEN_CLOCK_SKEW_SECONDS = 60;
104
+ /** Erreur de `verifyHgdcIdToken` et `verifiedOrganizationsFromIdToken`. */
105
+ export class HgdcIdTokenError extends Error {
106
+ reason;
107
+ constructor(reason, message) {
108
+ super(message);
109
+ this.name = "HgdcIdTokenError";
110
+ this.reason = reason;
111
+ }
112
+ }
113
+ function payloadOf(idToken) {
114
+ const segment = idToken.split(".")[1];
115
+ if (!segment)
116
+ throw new HgdcIdTokenError("unreadable", "id_token HGDC illisible");
117
+ let payload;
118
+ try {
119
+ payload = JSON.parse(decodeBase64Url(segment));
120
+ }
121
+ catch {
122
+ throw new HgdcIdTokenError("unreadable", "id_token HGDC illisible");
123
+ }
124
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
125
+ throw new HgdcIdTokenError("unreadable", "id_token HGDC illisible");
126
+ return payload;
127
+ }
128
+ /**
129
+ * Contrôle de provenance et de fraîcheur d'un id_token lu dans la table `account` de better-auth,
130
+ * avant d'en recopier quoi que ce soit (OpenID Connect Core 3.1.3.7) :
131
+ * - `iss` égal à l'émetteur attendu ;
132
+ * - `aud` contient `clientId`, et `azp` vaut `clientId` quand il y a plusieurs audiences ;
133
+ * - `iat` de moins de `ID_TOKEN_MAX_AGE_SECONDS`, pas dans le futur, et `exp` non dépassé.
134
+ *
135
+ * La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
136
+ * serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
137
+ * better-auth n'aurait pas réécrit, rejoue des organisations périmées.
138
+ */
139
+ export function verifyHgdcIdToken(idToken, expected) {
140
+ if (!expected.issuer || !expected.clientId)
141
+ throw new Error("verifyHgdcIdToken : émetteur ou clientId attendu manquant (HGDC_CLIENT_ID ?).");
142
+ const claims = payloadOf(idToken);
143
+ const issuer = typeof claims.iss === "string" ? claims.iss.replace(/\/+$/, "") : null;
144
+ if (issuer !== expected.issuer.replace(/\/+$/, ""))
145
+ throw new HgdcIdTokenError("wrong_issuer", "id_token d'un autre émetteur");
146
+ const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
147
+ if (!audience.includes(expected.clientId))
148
+ throw new HgdcIdTokenError("wrong_audience", "id_token destiné à un autre client");
149
+ if (audience.length > 1 && claims.azp !== expected.clientId)
150
+ throw new HgdcIdTokenError("wrong_audience", "id_token destiné à un autre client");
151
+ const now = Math.floor(expected.now / 1000);
152
+ const iat = typeof claims.iat === "number" ? claims.iat : null;
153
+ if (iat === null || now - iat > ID_TOKEN_MAX_AGE_SECONDS || iat - now > ID_TOKEN_CLOCK_SKEW_SECONDS)
154
+ throw new HgdcIdTokenError("stale", "id_token périmé");
155
+ if (claims.exp !== undefined && (typeof claims.exp !== "number" || now - claims.exp > ID_TOKEN_CLOCK_SKEW_SECONDS))
156
+ throw new HgdcIdTokenError("stale", "id_token périmé");
157
+ const result = organizationsClaim.safeParse(claims);
158
+ if (!result.success)
159
+ throw new HgdcIdTokenError("invalid_claims", `Organisations HGDC invalides : ${z.prettifyError(result.error)}`);
160
+ return { claims, organizations: result.data.organizations ?? [] };
161
+ }
162
+ /**
163
+ * Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
164
+ * `databaseHooks.session.create.before` : un jeton refusé fait échouer la connexion plutôt que
165
+ * d'ouvrir une session sur des droits périmés.
166
+ *
167
+ * ```ts
168
+ * const organizations = verifiedOrganizationsFromIdToken(account.idToken, {
169
+ * issuer: HGDC_ISSUER,
170
+ * clientId: process.env.HGDC_CLIENT_ID!,
171
+ * now: Date.now(),
172
+ * })
173
+ * ```
174
+ */
175
+ export function verifiedOrganizationsFromIdToken(idToken, expected) {
176
+ return verifyHgdcIdToken(idToken, expected).organizations;
177
+ }
178
+ /**
179
+ * Durée d'une session d'un produit, sans prolongation (`session: { expiresIn: SESSION_TTL_SECONDS,
180
+ * disableSessionRefresh: true }`) : les organisations sont relues à chaque connexion, donc au
181
+ * moins une fois tous les 7 jours.
182
+ */
183
+ export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
184
+ /**
185
+ * Âge maximal d'une appartenance copiée depuis Account, en millisecondes : 8 jours, soit une
186
+ * session de 7 jours et un jour de marge. Une copie plus ancienne n'est plus portée par aucune
187
+ * session (personne retirée dans Account, session d'avant la synchronisation) : elle n'ouvre plus rien.
188
+ */
189
+ export const MEMBERSHIP_TTL_MS = 8 * 24 * 60 * 60 * 1000;
190
+ /**
191
+ * Âge maximal de l'appartenance de la créatrice d'une clé d'API, qui vit hors session (CLI,
192
+ * intégrations) : 30 jours. Passé ce délai sans reconnexion, la clé cesse d'agir.
193
+ */
194
+ export const KEY_MEMBERSHIP_TTL_MS = 30 * 24 * 60 * 60 * 1000;
195
+ /**
196
+ * Vrai si l'appartenance a été confirmée par Account il y a moins de `ttl` (par défaut
197
+ * `MEMBERSHIP_TTL_MS`). `syncedAt` : dernière synchronisation réussie, en millisecondes, mise
198
+ * à jour même quand rien n'a changé. Une copie sans date n'est jamais fraîche.
199
+ */
200
+ export function membershipIsFresh(membership, now, ttl = MEMBERSHIP_TTL_MS) {
201
+ const { syncedAt } = membership;
202
+ return typeof syncedAt === "number" && now - syncedAt <= ttl;
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/auth",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",