@hgdc/auth 0.2.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 +54 -7
- package/dist/convex.d.ts +19 -1
- package/dist/convex.js +36 -7
- package/dist/index.d.ts +100 -0
- package/dist/index.js +145 -0
- package/package.json +1 -1
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` |
|
|
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,8 +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.
|
|
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
|
|
|
91
|
+
## Synchroniser les organisations
|
|
92
|
+
|
|
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 :
|
|
94
|
+
|
|
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`).
|
|
98
|
+
|
|
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
|
|
109
|
+
const plan = planOrganizationSync(claimed, membershipsOfThisUser)
|
|
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
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
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.
|
|
120
|
+
|
|
74
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`,
|
|
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
|
-
|
|
4
|
-
|
|
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("
|
|
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("
|
|
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`,
|
|
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("
|
|
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("
|
|
78
|
+
throw authError("insufficient_role");
|
|
50
79
|
}
|
|
51
80
|
return { identity, userId, membership, role };
|
|
52
81
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,3 +38,103 @@ export type HgdcProfile = {
|
|
|
38
38
|
* l'adresse n'est pas vérifiée : un tel profil ne doit jamais ouvrir de session.
|
|
39
39
|
*/
|
|
40
40
|
export declare function parseHgdcProfile(claims: unknown): HgdcProfile;
|
|
41
|
+
/**
|
|
42
|
+
* Organisations d'un id_token de HGDC Account. À n'appeler que sur un jeton reçu directement
|
|
43
|
+
* du point de jeton d'Account par le serveur (champ `idToken` de la table `account` de
|
|
44
|
+
* better-auth) : sa signature n'est pas revérifiée ici (OpenID Connect Core 3.1.3.7, point 6).
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
export declare function organizationsFromIdToken(idToken: string): HgdcOrganization[];
|
|
50
|
+
/** Appartenance d'une personne telle qu'un produit l'a copiée depuis Account. */
|
|
51
|
+
export type SyncedMembership = {
|
|
52
|
+
organizationId: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
name: string;
|
|
55
|
+
role: OrgRole;
|
|
56
|
+
};
|
|
57
|
+
export type OrganizationSyncPlan = {
|
|
58
|
+
/** Organisations à créer ou à mettre à jour chez le produit (espace et appartenance). */
|
|
59
|
+
upsert: HgdcOrganization[];
|
|
60
|
+
/** Organisations dont la personne ne fait plus partie : appartenance à retirer. */
|
|
61
|
+
remove: string[];
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Écart entre les organisations reçues d'Account et la copie du produit, pour une personne.
|
|
65
|
+
* Ne renvoie que ce qui change : une connexion sans changement n'écrit rien.
|
|
66
|
+
*/
|
|
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
|
@@ -56,3 +56,148 @@ export function parseHgdcProfile(claims) {
|
|
|
56
56
|
throw new Error(`Profil HGDC invalide : ${z.prettifyError(result.error)}`);
|
|
57
57
|
return result.data;
|
|
58
58
|
}
|
|
59
|
+
const organizationsClaim = z.object({ organizations: z.array(organizationClaim).optional() });
|
|
60
|
+
function decodeBase64Url(segment) {
|
|
61
|
+
const base64 = segment
|
|
62
|
+
.replaceAll("-", "+")
|
|
63
|
+
.replaceAll("_", "/")
|
|
64
|
+
.padEnd(Math.ceil(segment.length / 4) * 4, "=");
|
|
65
|
+
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
66
|
+
return new TextDecoder().decode(bytes);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Organisations d'un id_token de HGDC Account. À n'appeler que sur un jeton reçu directement
|
|
70
|
+
* du point de jeton d'Account par le serveur (champ `idToken` de la table `account` de
|
|
71
|
+
* better-auth) : sa signature n'est pas revérifiée ici (OpenID Connect Core 3.1.3.7, point 6).
|
|
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.
|
|
75
|
+
*/
|
|
76
|
+
export function organizationsFromIdToken(idToken) {
|
|
77
|
+
const payload = idToken.split(".")[1];
|
|
78
|
+
if (!payload)
|
|
79
|
+
throw new Error("id_token HGDC illisible");
|
|
80
|
+
const result = organizationsClaim.safeParse(JSON.parse(decodeBase64Url(payload)));
|
|
81
|
+
if (!result.success)
|
|
82
|
+
throw new Error(`Organisations HGDC invalides : ${z.prettifyError(result.error)}`);
|
|
83
|
+
return result.data.organizations ?? [];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Écart entre les organisations reçues d'Account et la copie du produit, pour une personne.
|
|
87
|
+
* Ne renvoie que ce qui change : une connexion sans changement n'écrit rien.
|
|
88
|
+
*/
|
|
89
|
+
export function planOrganizationSync(claimed, current) {
|
|
90
|
+
const known = new Map(current.map((m) => [m.organizationId, m]));
|
|
91
|
+
const claimedIds = new Set(claimed.map((o) => o.id));
|
|
92
|
+
return {
|
|
93
|
+
upsert: claimed.filter((org) => {
|
|
94
|
+
const copy = known.get(org.id);
|
|
95
|
+
return !copy || copy.role !== org.role || copy.slug !== org.slug || copy.name !== org.name;
|
|
96
|
+
}),
|
|
97
|
+
remove: current.filter((m) => !claimedIds.has(m.organizationId)).map((m) => m.organizationId),
|
|
98
|
+
};
|
|
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
|
+
"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",
|