@hgdc/auth 0.3.0 → 0.5.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 +71 -16
- package/dist/convex.d.ts +19 -1
- package/dist/convex.js +36 -7
- package/dist/index.d.ts +94 -0
- package/dist/index.js +155 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +106 -1
- 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`, 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
|
+
| `@hgdc/auth/server` | `hgdcProvider` (plugin `genericOAuth`), `createAuthProxy`, `restoreFetchMode`, `createHgdcEntry` |
|
|
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
|
|
|
@@ -55,6 +55,31 @@ authComponent.registerRoutes(http, (ctx) => {
|
|
|
55
55
|
})
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
## Entrée commune `/auth/hgdc`
|
|
59
|
+
|
|
60
|
+
Le menu « Produits » (`@hgdc/ui/account-menu`) envoie vers `https://<mot>.hgdc.ch/auth/hgdc`, pas vers la page d'accueil publique. Chaque produit monte cette route serveur, au même chemin :
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// src/routes/auth/hgdc.ts
|
|
64
|
+
import { createHgdcEntry } from "@hgdc/auth/server"
|
|
65
|
+
import { createFileRoute } from "@tanstack/react-router"
|
|
66
|
+
|
|
67
|
+
import { proxyAuth } from "@/lib/auth-server" // le proxy /api/auth (createAuthProxy)
|
|
68
|
+
|
|
69
|
+
const entry = createHgdcEntry({ proxy: proxyAuth, errorCallbackURL: "/sign-in?error=1" })
|
|
70
|
+
|
|
71
|
+
export const Route = createFileRoute("/auth/hgdc")({
|
|
72
|
+
server: { handlers: { GET: ({ request }) => entry(request) } },
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Session du produit ouverte : redirection vers `next`.
|
|
77
|
+
- Sinon la connexion HGDC part aussitôt, côté serveur (même appel que le bouton), avec retour vers `next`. Session d'Account ouverte et produit autorisé : Account renvoie sans formulaire.
|
|
78
|
+
- `next` (`?next=/o/acme`) n'accepte qu'un chemin du produit (`safeNextPath`) ; tout le reste devient `/`, que chaque produit redirige vers son app quand la session existe. `errorCallbackURL` : la page de connexion du produit avec son paramètre d'erreur.
|
|
79
|
+
- Lien vers un produit depuis ailleurs : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
|
|
80
|
+
|
|
81
|
+
Décision : ADR 0010.
|
|
82
|
+
|
|
58
83
|
## Garder une fonction Convex
|
|
59
84
|
|
|
60
85
|
```ts
|
|
@@ -67,25 +92,55 @@ const { userId, role } = await requireOrgMember(ctx, organizationId, {
|
|
|
67
92
|
})
|
|
68
93
|
```
|
|
69
94
|
|
|
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.
|
|
95
|
+
À 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.
|
|
96
|
+
|
|
97
|
+
Un refus est une `ConvexError({ code, reason, message })`. `message` s'affiche tel quel (tutoiement) ; pour décider quoi montrer, lire `reason`, jamais le texte :
|
|
98
|
+
|
|
99
|
+
| `reason` | `code` | Cas |
|
|
100
|
+
| ------------------- | ----------------- | ----------------------------------------- |
|
|
101
|
+
| `no_session` | `UNAUTHENTICATED` | Personne n'est connecté |
|
|
102
|
+
| `wrong_issuer` | `UNAUTHENTICATED` | Jeton d'un autre émetteur |
|
|
103
|
+
| `not_member` | `FORBIDDEN` | La personne n'est pas dans l'organisation |
|
|
104
|
+
| `insufficient_role` | `FORBIDDEN` | Elle en est membre, rôle insuffisant |
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { authErrorReason } from "@hgdc/auth/convex"
|
|
108
|
+
|
|
109
|
+
if (authErrorReason(error) === "insufficient_role") showUpgradeHint()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`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
113
|
|
|
72
114
|
Les organisations arrivent par la portée `organizations` : claim `organizations: [{ id, slug, name, role }]` de l'id_token et de userinfo.
|
|
73
115
|
|
|
74
116
|
## Synchroniser les organisations
|
|
75
117
|
|
|
76
|
-
Les organisations vivent dans Account ; le produit en garde une copie (espace par organisation, appartenances) que lit le chargeur de `requireOrgMember`.
|
|
118
|
+
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
119
|
|
|
78
|
-
|
|
79
|
-
|
|
120
|
+
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.
|
|
121
|
+
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.
|
|
122
|
+
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
123
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
124
|
+
```ts
|
|
125
|
+
import { HGDC_ISSUER, membershipIsFresh, planOrganizationSync, verifiedOrganizationsFromIdToken } from "@hgdc/auth"
|
|
126
|
+
|
|
127
|
+
// better-auth : databaseHooks.session.create.before
|
|
128
|
+
const hgdc = accounts.find((a) => a.providerId === "hgdc") // exactement un, sinon refuser
|
|
129
|
+
const claimed = verifiedOrganizationsFromIdToken(hgdc.idToken ?? "", {
|
|
130
|
+
issuer: HGDC_ISSUER,
|
|
131
|
+
clientId: process.env.HGDC_CLIENT_ID!,
|
|
132
|
+
now: Date.now(),
|
|
133
|
+
}) // lève HgdcIdTokenError : émetteur, audience (clientId), iat de plus de 10 minutes
|
|
84
134
|
const plan = planOrganizationSync(claimed, membershipsOfThisUser)
|
|
85
|
-
// plan.upsert
|
|
86
|
-
|
|
135
|
+
// mutation interne : plan.upsert (espace + appartenance), plan.remove, et syncedAt = Date.now() partout
|
|
136
|
+
|
|
137
|
+
// chargeur de requireOrgMember
|
|
138
|
+
load: async (ctx, { organizationId, userId }) => {
|
|
139
|
+
const member = /* appartenance lue dans la base du produit */
|
|
140
|
+
return member && membershipIsFresh(member, Date.now()) ? member : null
|
|
141
|
+
}
|
|
87
142
|
```
|
|
88
143
|
|
|
89
|
-
|
|
144
|
+
`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
145
|
|
|
91
146
|
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
|
@@ -6,6 +6,25 @@ export declare const HGDC_ISSUER = "https://account.hgdc.ch/api/auth";
|
|
|
6
6
|
export declare const ORGANIZATIONS_SCOPE = "organizations";
|
|
7
7
|
/** Portées demandées par défaut : identité, adresse vérifiée, renouvellement et organisations. */
|
|
8
8
|
export declare const HGDC_SCOPES: readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* Entrée commune des produits : `https://<mot>.hgdc.ch/auth/hgdc?next=<chemin>` ouvre le produit
|
|
11
|
+
* avec la session HGDC Account, sans page d'accueil ni bouton (`createHgdcEntry`, `@hgdc/auth/server`).
|
|
12
|
+
* Le menu « Produits » de `@hgdc/ui` y pointe : même chemin dans chaque produit.
|
|
13
|
+
*/
|
|
14
|
+
export declare const HGDC_ENTRY_PATH = "/auth/hgdc";
|
|
15
|
+
/** Paramètre de l'entrée qui porte la page d'arrivée. */
|
|
16
|
+
export declare const HGDC_ENTRY_NEXT_PARAM = "next";
|
|
17
|
+
/**
|
|
18
|
+
* Page d'arrivée sûre : un chemin du produit lui-même, jamais une autre origine. Refuse ce qu'un
|
|
19
|
+
* navigateur lirait comme une adresse externe (`//hôte`, `/\hôte`, schéma), les caractères de
|
|
20
|
+
* contrôle, et l'entrée ou `/api/auth` (boucle). Rend `fallback` dans tous ces cas.
|
|
21
|
+
*/
|
|
22
|
+
export declare function safeNextPath(value: string | null | undefined, fallback?: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Adresse de l'entrée commune d'un produit : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
|
|
25
|
+
* Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée.
|
|
26
|
+
*/
|
|
27
|
+
export declare function hgdcEntryUrl(productOrigin: string, next?: string): string;
|
|
9
28
|
/** Rôles d'organisation, du plus faible au plus fort. */
|
|
10
29
|
export declare const ORG_ROLES: readonly ["member", "admin", "owner"];
|
|
11
30
|
export type OrgRole = (typeof ORG_ROLES)[number];
|
|
@@ -43,6 +62,8 @@ export declare function parseHgdcProfile(claims: unknown): HgdcProfile;
|
|
|
43
62
|
* du point de jeton d'Account par le serveur (champ `idToken` de la table `account` de
|
|
44
63
|
* better-auth) : sa signature n'est pas revérifiée ici (OpenID Connect Core 3.1.3.7, point 6).
|
|
45
64
|
* Lève une erreur si le jeton est illisible ou si un claim est invalide ; `[]` sans la portée.
|
|
65
|
+
* À la connexion, préférer `verifiedOrganizationsFromIdToken`, qui contrôle aussi émetteur,
|
|
66
|
+
* audience et fraîcheur.
|
|
46
67
|
*/
|
|
47
68
|
export declare function organizationsFromIdToken(idToken: string): HgdcOrganization[];
|
|
48
69
|
/** Appartenance d'une personne telle qu'un produit l'a copiée depuis Account. */
|
|
@@ -63,3 +84,76 @@ export type OrganizationSyncPlan = {
|
|
|
63
84
|
* Ne renvoie que ce qui change : une connexion sans changement n'écrit rien.
|
|
64
85
|
*/
|
|
65
86
|
export declare function planOrganizationSync(claimed: readonly HgdcOrganization[], current: readonly SyncedMembership[]): OrganizationSyncPlan;
|
|
87
|
+
/** Âge maximal d'un id_token lu à la connexion : il vient d'être reçu du point de jeton d'Account. */
|
|
88
|
+
export declare const ID_TOKEN_MAX_AGE_SECONDS: number;
|
|
89
|
+
/** Avance d'horloge tolérée entre Account et le produit, en secondes. */
|
|
90
|
+
export declare const ID_TOKEN_CLOCK_SKEW_SECONDS: number;
|
|
91
|
+
/** Pourquoi un id_token est refusé, pour les journaux : la personne ne voit que « Connexion HGDC requise ». */
|
|
92
|
+
export type IdTokenRejection = "unreadable" | "wrong_issuer" | "wrong_audience" | "stale" | "invalid_claims";
|
|
93
|
+
/** Erreur de `verifyHgdcIdToken` et `verifiedOrganizationsFromIdToken`. */
|
|
94
|
+
export declare class HgdcIdTokenError extends Error {
|
|
95
|
+
readonly reason: IdTokenRejection;
|
|
96
|
+
constructor(reason: IdTokenRejection, message: string);
|
|
97
|
+
}
|
|
98
|
+
export type IdTokenExpectation = {
|
|
99
|
+
/** Émetteur attendu : `HGDC_ISSUER`, ou celui d'un Account de préproduction. */
|
|
100
|
+
issuer: string;
|
|
101
|
+
/** Identifiant du client du produit : l'audience du jeton. */
|
|
102
|
+
clientId: string;
|
|
103
|
+
/** Instant présent en millisecondes (`Date.now()`). */
|
|
104
|
+
now: number;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Contrôle de provenance et de fraîcheur d'un id_token lu dans la table `account` de better-auth,
|
|
108
|
+
* avant d'en recopier quoi que ce soit (OpenID Connect Core 3.1.3.7) :
|
|
109
|
+
* - `iss` égal à l'émetteur attendu ;
|
|
110
|
+
* - `aud` contient `clientId`, et `azp` vaut `clientId` quand il y a plusieurs audiences ;
|
|
111
|
+
* - `iat` de moins de `ID_TOKEN_MAX_AGE_SECONDS`, pas dans le futur, et `exp` non dépassé.
|
|
112
|
+
*
|
|
113
|
+
* La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
|
|
114
|
+
* serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
|
|
115
|
+
* better-auth n'aurait pas réécrit, rejoue des organisations périmées.
|
|
116
|
+
*/
|
|
117
|
+
export declare function verifyHgdcIdToken(idToken: string, expected: IdTokenExpectation): {
|
|
118
|
+
claims: Record<string, unknown>;
|
|
119
|
+
organizations: HgdcOrganization[];
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
|
|
123
|
+
* `databaseHooks.session.create.before` : un jeton refusé fait échouer la connexion plutôt que
|
|
124
|
+
* d'ouvrir une session sur des droits périmés.
|
|
125
|
+
*
|
|
126
|
+
* ```ts
|
|
127
|
+
* const organizations = verifiedOrganizationsFromIdToken(account.idToken, {
|
|
128
|
+
* issuer: HGDC_ISSUER,
|
|
129
|
+
* clientId: process.env.HGDC_CLIENT_ID!,
|
|
130
|
+
* now: Date.now(),
|
|
131
|
+
* })
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export declare function verifiedOrganizationsFromIdToken(idToken: string, expected: IdTokenExpectation): HgdcOrganization[];
|
|
135
|
+
/**
|
|
136
|
+
* Durée d'une session d'un produit, sans prolongation (`session: { expiresIn: SESSION_TTL_SECONDS,
|
|
137
|
+
* disableSessionRefresh: true }`) : les organisations sont relues à chaque connexion, donc au
|
|
138
|
+
* moins une fois tous les 7 jours.
|
|
139
|
+
*/
|
|
140
|
+
export declare const SESSION_TTL_SECONDS: number;
|
|
141
|
+
/**
|
|
142
|
+
* Âge maximal d'une appartenance copiée depuis Account, en millisecondes : 8 jours, soit une
|
|
143
|
+
* session de 7 jours et un jour de marge. Une copie plus ancienne n'est plus portée par aucune
|
|
144
|
+
* session (personne retirée dans Account, session d'avant la synchronisation) : elle n'ouvre plus rien.
|
|
145
|
+
*/
|
|
146
|
+
export declare const MEMBERSHIP_TTL_MS: number;
|
|
147
|
+
/**
|
|
148
|
+
* Âge maximal de l'appartenance de la créatrice d'une clé d'API, qui vit hors session (CLI,
|
|
149
|
+
* intégrations) : 30 jours. Passé ce délai sans reconnexion, la clé cesse d'agir.
|
|
150
|
+
*/
|
|
151
|
+
export declare const KEY_MEMBERSHIP_TTL_MS: number;
|
|
152
|
+
/**
|
|
153
|
+
* Vrai si l'appartenance a été confirmée par Account il y a moins de `ttl` (par défaut
|
|
154
|
+
* `MEMBERSHIP_TTL_MS`). `syncedAt` : dernière synchronisation réussie, en millisecondes, mise
|
|
155
|
+
* à jour même quand rien n'a changé. Une copie sans date n'est jamais fraîche.
|
|
156
|
+
*/
|
|
157
|
+
export declare function membershipIsFresh(membership: {
|
|
158
|
+
syncedAt?: number | null | undefined;
|
|
159
|
+
}, now: number, ttl?: number): boolean;
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,55 @@ export const HGDC_ISSUER = "https://account.hgdc.ch/api/auth";
|
|
|
7
7
|
export const ORGANIZATIONS_SCOPE = "organizations";
|
|
8
8
|
/** Portées demandées par défaut : identité, adresse vérifiée, renouvellement et organisations. */
|
|
9
9
|
export const HGDC_SCOPES = ["openid", "profile", "email", "offline_access", ORGANIZATIONS_SCOPE];
|
|
10
|
+
/**
|
|
11
|
+
* Entrée commune des produits : `https://<mot>.hgdc.ch/auth/hgdc?next=<chemin>` ouvre le produit
|
|
12
|
+
* avec la session HGDC Account, sans page d'accueil ni bouton (`createHgdcEntry`, `@hgdc/auth/server`).
|
|
13
|
+
* Le menu « Produits » de `@hgdc/ui` y pointe : même chemin dans chaque produit.
|
|
14
|
+
*/
|
|
15
|
+
export const HGDC_ENTRY_PATH = "/auth/hgdc";
|
|
16
|
+
/** Paramètre de l'entrée qui porte la page d'arrivée. */
|
|
17
|
+
export const HGDC_ENTRY_NEXT_PARAM = "next";
|
|
18
|
+
const NEXT_MAX_LENGTH = 2048;
|
|
19
|
+
const NEXT_BASE = "https://next.invalid";
|
|
20
|
+
/**
|
|
21
|
+
* Page d'arrivée sûre : un chemin du produit lui-même, jamais une autre origine. Refuse ce qu'un
|
|
22
|
+
* navigateur lirait comme une adresse externe (`//hôte`, `/\hôte`, schéma), les caractères de
|
|
23
|
+
* contrôle, et l'entrée ou `/api/auth` (boucle). Rend `fallback` dans tous ces cas.
|
|
24
|
+
*/
|
|
25
|
+
export function safeNextPath(value, fallback = "/") {
|
|
26
|
+
if (typeof value !== "string" || value.length === 0 || value.length > NEXT_MAX_LENGTH)
|
|
27
|
+
return fallback;
|
|
28
|
+
if (!value.startsWith("/") || value.startsWith("//"))
|
|
29
|
+
return fallback;
|
|
30
|
+
// Les navigateurs lisent `\` comme `/` : `/\evil.ch` serait une adresse externe.
|
|
31
|
+
// oxlint-disable-next-line no-control-regex -- refuser les caractères de contrôle est le but
|
|
32
|
+
if (/[\\\u0000-\u001f\u007f]/.test(value))
|
|
33
|
+
return fallback;
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(value, NEXT_BASE);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
if (url.origin !== NEXT_BASE)
|
|
42
|
+
return fallback;
|
|
43
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
44
|
+
if (path === HGDC_ENTRY_PATH || path === "/api/auth" || path.startsWith("/api/auth/"))
|
|
45
|
+
return fallback;
|
|
46
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Adresse de l'entrée commune d'un produit : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
|
|
50
|
+
* Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée.
|
|
51
|
+
*/
|
|
52
|
+
export function hgdcEntryUrl(productOrigin, next) {
|
|
53
|
+
const url = new URL(HGDC_ENTRY_PATH, new URL(productOrigin).origin);
|
|
54
|
+
const path = next === undefined ? "/" : safeNextPath(next);
|
|
55
|
+
if (path !== "/")
|
|
56
|
+
url.searchParams.set(HGDC_ENTRY_NEXT_PARAM, path);
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
10
59
|
/** Rôles d'organisation, du plus faible au plus fort. */
|
|
11
60
|
export const ORG_ROLES = ["member", "admin", "owner"];
|
|
12
61
|
function isOrgRole(value) {
|
|
@@ -70,6 +119,8 @@ function decodeBase64Url(segment) {
|
|
|
70
119
|
* du point de jeton d'Account par le serveur (champ `idToken` de la table `account` de
|
|
71
120
|
* better-auth) : sa signature n'est pas revérifiée ici (OpenID Connect Core 3.1.3.7, point 6).
|
|
72
121
|
* Lève une erreur si le jeton est illisible ou si un claim est invalide ; `[]` sans la portée.
|
|
122
|
+
* À la connexion, préférer `verifiedOrganizationsFromIdToken`, qui contrôle aussi émetteur,
|
|
123
|
+
* audience et fraîcheur.
|
|
73
124
|
*/
|
|
74
125
|
export function organizationsFromIdToken(idToken) {
|
|
75
126
|
const payload = idToken.split(".")[1];
|
|
@@ -95,3 +146,107 @@ export function planOrganizationSync(claimed, current) {
|
|
|
95
146
|
remove: current.filter((m) => !claimedIds.has(m.organizationId)).map((m) => m.organizationId),
|
|
96
147
|
};
|
|
97
148
|
}
|
|
149
|
+
/** Âge maximal d'un id_token lu à la connexion : il vient d'être reçu du point de jeton d'Account. */
|
|
150
|
+
export const ID_TOKEN_MAX_AGE_SECONDS = 10 * 60;
|
|
151
|
+
/** Avance d'horloge tolérée entre Account et le produit, en secondes. */
|
|
152
|
+
export const ID_TOKEN_CLOCK_SKEW_SECONDS = 60;
|
|
153
|
+
/** Erreur de `verifyHgdcIdToken` et `verifiedOrganizationsFromIdToken`. */
|
|
154
|
+
export class HgdcIdTokenError extends Error {
|
|
155
|
+
reason;
|
|
156
|
+
constructor(reason, message) {
|
|
157
|
+
super(message);
|
|
158
|
+
this.name = "HgdcIdTokenError";
|
|
159
|
+
this.reason = reason;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function payloadOf(idToken) {
|
|
163
|
+
const segment = idToken.split(".")[1];
|
|
164
|
+
if (!segment)
|
|
165
|
+
throw new HgdcIdTokenError("unreadable", "id_token HGDC illisible");
|
|
166
|
+
let payload;
|
|
167
|
+
try {
|
|
168
|
+
payload = JSON.parse(decodeBase64Url(segment));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new HgdcIdTokenError("unreadable", "id_token HGDC illisible");
|
|
172
|
+
}
|
|
173
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
174
|
+
throw new HgdcIdTokenError("unreadable", "id_token HGDC illisible");
|
|
175
|
+
return payload;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Contrôle de provenance et de fraîcheur d'un id_token lu dans la table `account` de better-auth,
|
|
179
|
+
* avant d'en recopier quoi que ce soit (OpenID Connect Core 3.1.3.7) :
|
|
180
|
+
* - `iss` égal à l'émetteur attendu ;
|
|
181
|
+
* - `aud` contient `clientId`, et `azp` vaut `clientId` quand il y a plusieurs audiences ;
|
|
182
|
+
* - `iat` de moins de `ID_TOKEN_MAX_AGE_SECONDS`, pas dans le futur, et `exp` non dépassé.
|
|
183
|
+
*
|
|
184
|
+
* La signature n'est pas revérifiée : le jeton vient du point de jeton d'Account, reçu par le
|
|
185
|
+
* serveur en TLS (point 6). Le contrôle de fraîcheur empêche qu'un jeton ancien, que
|
|
186
|
+
* better-auth n'aurait pas réécrit, rejoue des organisations périmées.
|
|
187
|
+
*/
|
|
188
|
+
export function verifyHgdcIdToken(idToken, expected) {
|
|
189
|
+
if (!expected.issuer || !expected.clientId)
|
|
190
|
+
throw new Error("verifyHgdcIdToken : émetteur ou clientId attendu manquant (HGDC_CLIENT_ID ?).");
|
|
191
|
+
const claims = payloadOf(idToken);
|
|
192
|
+
const issuer = typeof claims.iss === "string" ? claims.iss.replace(/\/+$/, "") : null;
|
|
193
|
+
if (issuer !== expected.issuer.replace(/\/+$/, ""))
|
|
194
|
+
throw new HgdcIdTokenError("wrong_issuer", "id_token d'un autre émetteur");
|
|
195
|
+
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
196
|
+
if (!audience.includes(expected.clientId))
|
|
197
|
+
throw new HgdcIdTokenError("wrong_audience", "id_token destiné à un autre client");
|
|
198
|
+
if (audience.length > 1 && claims.azp !== expected.clientId)
|
|
199
|
+
throw new HgdcIdTokenError("wrong_audience", "id_token destiné à un autre client");
|
|
200
|
+
const now = Math.floor(expected.now / 1000);
|
|
201
|
+
const iat = typeof claims.iat === "number" ? claims.iat : null;
|
|
202
|
+
if (iat === null || now - iat > ID_TOKEN_MAX_AGE_SECONDS || iat - now > ID_TOKEN_CLOCK_SKEW_SECONDS)
|
|
203
|
+
throw new HgdcIdTokenError("stale", "id_token périmé");
|
|
204
|
+
if (claims.exp !== undefined && (typeof claims.exp !== "number" || now - claims.exp > ID_TOKEN_CLOCK_SKEW_SECONDS))
|
|
205
|
+
throw new HgdcIdTokenError("stale", "id_token périmé");
|
|
206
|
+
const result = organizationsClaim.safeParse(claims);
|
|
207
|
+
if (!result.success)
|
|
208
|
+
throw new HgdcIdTokenError("invalid_claims", `Organisations HGDC invalides : ${z.prettifyError(result.error)}`);
|
|
209
|
+
return { claims, organizations: result.data.organizations ?? [] };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Organisations d'un id_token reçu à la connexion, après `verifyHgdcIdToken`. À appeler dans
|
|
213
|
+
* `databaseHooks.session.create.before` : un jeton refusé fait échouer la connexion plutôt que
|
|
214
|
+
* d'ouvrir une session sur des droits périmés.
|
|
215
|
+
*
|
|
216
|
+
* ```ts
|
|
217
|
+
* const organizations = verifiedOrganizationsFromIdToken(account.idToken, {
|
|
218
|
+
* issuer: HGDC_ISSUER,
|
|
219
|
+
* clientId: process.env.HGDC_CLIENT_ID!,
|
|
220
|
+
* now: Date.now(),
|
|
221
|
+
* })
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
export function verifiedOrganizationsFromIdToken(idToken, expected) {
|
|
225
|
+
return verifyHgdcIdToken(idToken, expected).organizations;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Durée d'une session d'un produit, sans prolongation (`session: { expiresIn: SESSION_TTL_SECONDS,
|
|
229
|
+
* disableSessionRefresh: true }`) : les organisations sont relues à chaque connexion, donc au
|
|
230
|
+
* moins une fois tous les 7 jours.
|
|
231
|
+
*/
|
|
232
|
+
export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
233
|
+
/**
|
|
234
|
+
* Âge maximal d'une appartenance copiée depuis Account, en millisecondes : 8 jours, soit une
|
|
235
|
+
* session de 7 jours et un jour de marge. Une copie plus ancienne n'est plus portée par aucune
|
|
236
|
+
* session (personne retirée dans Account, session d'avant la synchronisation) : elle n'ouvre plus rien.
|
|
237
|
+
*/
|
|
238
|
+
export const MEMBERSHIP_TTL_MS = 8 * 24 * 60 * 60 * 1000;
|
|
239
|
+
/**
|
|
240
|
+
* Âge maximal de l'appartenance de la créatrice d'une clé d'API, qui vit hors session (CLI,
|
|
241
|
+
* intégrations) : 30 jours. Passé ce délai sans reconnexion, la clé cesse d'agir.
|
|
242
|
+
*/
|
|
243
|
+
export const KEY_MEMBERSHIP_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
244
|
+
/**
|
|
245
|
+
* Vrai si l'appartenance a été confirmée par Account il y a moins de `ttl` (par défaut
|
|
246
|
+
* `MEMBERSHIP_TTL_MS`). `syncedAt` : dernière synchronisation réussie, en millisecondes, mise
|
|
247
|
+
* à jour même quand rien n'a changé. Une copie sans date n'est jamais fraîche.
|
|
248
|
+
*/
|
|
249
|
+
export function membershipIsFresh(membership, now, ttl = MEMBERSHIP_TTL_MS) {
|
|
250
|
+
const { syncedAt } = membership;
|
|
251
|
+
return typeof syncedAt === "number" && now - syncedAt <= ttl;
|
|
252
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -19,6 +19,39 @@ export declare function restoreFetchMode(request: Request): Request;
|
|
|
19
19
|
* `x-hgdc-fetch-mode`.
|
|
20
20
|
*/
|
|
21
21
|
export declare function createAuthProxy(convexSiteUrl: string, fetchImpl?: typeof fetch): (request: Request) => Promise<Response>;
|
|
22
|
+
export type HgdcEntryOptions = {
|
|
23
|
+
/** Le proxy `/api/auth` du produit (`createAuthProxy`). */
|
|
24
|
+
proxy: (request: Request) => Promise<Response>;
|
|
25
|
+
/**
|
|
26
|
+
* Page du produit si la connexion échoue (Account refuse, réseau) : sa page de connexion avec
|
|
27
|
+
* son paramètre d'erreur, par exemple `/sign-in?error=1`. Chemin relatif du produit.
|
|
28
|
+
*/
|
|
29
|
+
errorCallbackURL: string;
|
|
30
|
+
/** Chemin de better-auth, `/api/auth` par défaut. */
|
|
31
|
+
basePath?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Entrée commune `GET /auth/hgdc?next=<chemin>` (`HGDC_ENTRY_PATH`), à monter comme route
|
|
35
|
+
* serveur du produit. Le menu « Produits » de `@hgdc/ui` y envoie : on entre dans le produit
|
|
36
|
+
* sans passer par sa page d'accueil ni recliquer « Se connecter avec HGDC ».
|
|
37
|
+
*
|
|
38
|
+
* - Session du produit ouverte : redirection vers `next`.
|
|
39
|
+
* - Sinon : la connexion HGDC part aussitôt (même appel que `HgdcSignInButton`, fait par le
|
|
40
|
+
* serveur), retour vers `next`. Si la session d'Account est ouverte et le produit déjà
|
|
41
|
+
* autorisé, Account renvoie sans formulaire : trois redirections, aucun clic.
|
|
42
|
+
*
|
|
43
|
+
* `next` est filtré par `safeNextPath` (chemin du produit seulement, pas de redirection ouverte),
|
|
44
|
+
* puis better-auth le revérifie contre ses `trustedOrigins`.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* // src/routes/auth/hgdc.ts
|
|
48
|
+
* const entry = createHgdcEntry({ proxy: proxyAuth, errorCallbackURL: "/sign-in?error=1" })
|
|
49
|
+
* export const Route = createFileRoute("/auth/hgdc")({
|
|
50
|
+
* server: { handlers: { GET: ({ request }) => entry(request) } },
|
|
51
|
+
* })
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function createHgdcEntry(options: HgdcEntryOptions): (request: Request) => Promise<Response>;
|
|
22
55
|
export type HgdcProviderOptions = {
|
|
23
56
|
/** Identifiant du client, remis par HGDC à l'enregistrement du produit. */
|
|
24
57
|
clientId: string;
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HGDC_ISSUER, HGDC_PROVIDER_ID, HGDC_SCOPES, parseHgdcProfile } from "./index.js";
|
|
1
|
+
import { HGDC_ENTRY_NEXT_PARAM, HGDC_ISSUER, HGDC_PROVIDER_ID, HGDC_SCOPES, parseHgdcProfile, safeNextPath, } from "./index.js";
|
|
2
2
|
/** En-tête qui porte le `sec-fetch-mode` du navigateur à travers le proxy. */
|
|
3
3
|
export const FETCH_MODE_HEADER = "x-hgdc-fetch-mode";
|
|
4
4
|
const FETCH_MODES = new Set(["navigate", "cors", "no-cors", "same-origin", "websocket"]);
|
|
@@ -55,6 +55,111 @@ export function createAuthProxy(convexSiteUrl, fetchImpl = fetch) {
|
|
|
55
55
|
});
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
+
const STRIPPED_ON_INTERNAL_CALL = [
|
|
59
|
+
"content-length",
|
|
60
|
+
"content-type",
|
|
61
|
+
"accept",
|
|
62
|
+
"sec-fetch-mode",
|
|
63
|
+
"sec-fetch-dest",
|
|
64
|
+
"sec-fetch-site",
|
|
65
|
+
"sec-fetch-user",
|
|
66
|
+
FETCH_MODE_HEADER,
|
|
67
|
+
];
|
|
68
|
+
function internalHeaders(request, origin) {
|
|
69
|
+
// Garde cookies, adresse IP et user-agent (limites de débit, sessions) ; retire le mode de
|
|
70
|
+
// navigation, sinon better-auth rendrait une redirection au lieu du JSON attendu.
|
|
71
|
+
const headers = new Headers(request.headers);
|
|
72
|
+
for (const name of STRIPPED_ON_INTERNAL_CALL)
|
|
73
|
+
headers.delete(name);
|
|
74
|
+
headers.set("accept", "application/json");
|
|
75
|
+
headers.set("origin", origin);
|
|
76
|
+
return headers;
|
|
77
|
+
}
|
|
78
|
+
function setCookies(response) {
|
|
79
|
+
return typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
|
|
80
|
+
}
|
|
81
|
+
function redirectTo(location, cookies = []) {
|
|
82
|
+
const headers = new Headers({ location, "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
83
|
+
for (const cookie of cookies)
|
|
84
|
+
headers.append("set-cookie", cookie);
|
|
85
|
+
return new Response(null, { status: 302, headers });
|
|
86
|
+
}
|
|
87
|
+
async function readJson(response) {
|
|
88
|
+
try {
|
|
89
|
+
return await response.json();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function hasSessionBody(body) {
|
|
96
|
+
return typeof body === "object" && body !== null && "session" in body && Boolean(body.session);
|
|
97
|
+
}
|
|
98
|
+
function authorizationUrl(body) {
|
|
99
|
+
if (typeof body !== "object" || body === null || !("url" in body) || typeof body.url !== "string")
|
|
100
|
+
return null;
|
|
101
|
+
try {
|
|
102
|
+
const url = new URL(body.url);
|
|
103
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Entrée commune `GET /auth/hgdc?next=<chemin>` (`HGDC_ENTRY_PATH`), à monter comme route
|
|
111
|
+
* serveur du produit. Le menu « Produits » de `@hgdc/ui` y envoie : on entre dans le produit
|
|
112
|
+
* sans passer par sa page d'accueil ni recliquer « Se connecter avec HGDC ».
|
|
113
|
+
*
|
|
114
|
+
* - Session du produit ouverte : redirection vers `next`.
|
|
115
|
+
* - Sinon : la connexion HGDC part aussitôt (même appel que `HgdcSignInButton`, fait par le
|
|
116
|
+
* serveur), retour vers `next`. Si la session d'Account est ouverte et le produit déjà
|
|
117
|
+
* autorisé, Account renvoie sans formulaire : trois redirections, aucun clic.
|
|
118
|
+
*
|
|
119
|
+
* `next` est filtré par `safeNextPath` (chemin du produit seulement, pas de redirection ouverte),
|
|
120
|
+
* puis better-auth le revérifie contre ses `trustedOrigins`.
|
|
121
|
+
*
|
|
122
|
+
* ```ts
|
|
123
|
+
* // src/routes/auth/hgdc.ts
|
|
124
|
+
* const entry = createHgdcEntry({ proxy: proxyAuth, errorCallbackURL: "/sign-in?error=1" })
|
|
125
|
+
* export const Route = createFileRoute("/auth/hgdc")({
|
|
126
|
+
* server: { handlers: { GET: ({ request }) => entry(request) } },
|
|
127
|
+
* })
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function createHgdcEntry(options) {
|
|
131
|
+
const errorCallbackURL = safeNextPath(options.errorCallbackURL, "");
|
|
132
|
+
if (!errorCallbackURL)
|
|
133
|
+
throw new Error("createHgdcEntry : errorCallbackURL doit être un chemin du produit.");
|
|
134
|
+
const basePath = (options.basePath ?? "/api/auth").replace(/\/+$/, "");
|
|
135
|
+
return async (request) => {
|
|
136
|
+
const url = new URL(request.url);
|
|
137
|
+
const next = safeNextPath(url.searchParams.get(HGDC_ENTRY_NEXT_PARAM));
|
|
138
|
+
const cookies = [];
|
|
139
|
+
try {
|
|
140
|
+
if (request.headers.has("cookie")) {
|
|
141
|
+
const session = await options.proxy(new Request(`${url.origin}${basePath}/get-session`, { headers: internalHeaders(request, url.origin) }));
|
|
142
|
+
cookies.push(...setCookies(session));
|
|
143
|
+
if (session.ok && hasSessionBody(await readJson(session)))
|
|
144
|
+
return redirectTo(next, cookies);
|
|
145
|
+
}
|
|
146
|
+
const headers = internalHeaders(request, url.origin);
|
|
147
|
+
headers.set("content-type", "application/json");
|
|
148
|
+
const signIn = await options.proxy(new Request(`${url.origin}${basePath}/sign-in/oauth2`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers,
|
|
151
|
+
body: JSON.stringify({ providerId: HGDC_PROVIDER_ID, callbackURL: next, errorCallbackURL }),
|
|
152
|
+
}));
|
|
153
|
+
const target = signIn.ok ? authorizationUrl(await readJson(signIn)) : null;
|
|
154
|
+
if (!target)
|
|
155
|
+
return redirectTo(errorCallbackURL, cookies);
|
|
156
|
+
return redirectTo(target, [...cookies, ...setCookies(signIn)]);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return redirectTo(errorCallbackURL, cookies);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
58
163
|
/**
|
|
59
164
|
* Configuration du fournisseur « HGDC » pour le plugin `genericOAuth` de better-auth :
|
|
60
165
|
* découverte OIDC, code d'autorisation avec PKCE, émetteur vérifié contre la confusion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|