@hgdc/auth 0.1.0 → 0.2.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 +69 -2
- package/dist/convex.d.ts +60 -0
- package/dist/convex.js +52 -0
- package/dist/index.d.ts +40 -1
- package/dist/index.js +58 -2
- package/dist/react.d.ts +46 -0
- package/dist/react.js +44 -0
- package/dist/server.d.ts +41 -0
- package/dist/server.js +92 -0
- package/package.json +40 -3
package/README.md
CHANGED
|
@@ -1,7 +1,74 @@
|
|
|
1
1
|
# @hgdc/auth
|
|
2
2
|
|
|
3
|
-
Connexion HGDC : bouton « Se connecter avec HGDC »,
|
|
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
|
-
|
|
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` |
|
|
11
|
+
|
|
12
|
+
Pairs : better-auth 1.6, convex 1.46+, React 19 (chacun facultatif selon l'import).
|
|
13
|
+
|
|
14
|
+
## Connecter un produit
|
|
15
|
+
|
|
16
|
+
1. Demander à HGDC l'enregistrement du produit : il rend un `clientId` et un `clientSecret`. Adresse de retour : `https://<produit>/api/auth/oauth2/callback/hgdc`.
|
|
17
|
+
2. Côté better-auth (serveur) :
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { hgdcProvider } from "@hgdc/auth/server"
|
|
21
|
+
import { genericOAuth } from "better-auth/plugins"
|
|
22
|
+
|
|
23
|
+
plugins: [
|
|
24
|
+
genericOAuth({
|
|
25
|
+
config: [hgdcProvider({ clientId: process.env.HGDC_CLIENT_ID!, clientSecret: process.env.HGDC_CLIENT_SECRET! })],
|
|
26
|
+
}),
|
|
27
|
+
]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Le profil reçu est validé (`parseHgdcProfile`) avant de créer ou relier un compte ; l'émetteur est vérifié et PKCE est actif. Relier un compte existant par adresse e-mail reste un choix du produit (`account.accountLinking.trustedProviders: ["hgdc"]`).
|
|
31
|
+
|
|
32
|
+
3. Côté client : plugin `genericOAuthClient()` dans `createAuthClient`, puis
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { HgdcSignInButton } from "@hgdc/auth/react"
|
|
36
|
+
|
|
37
|
+
<HgdcSignInButton authClient={authClient} callbackURL="/tableau-de-bord" className="…" onError={…} />
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Le bouton n'a pas de style propre : le produit le met en forme. L'attente est exposée par `aria-busy` et `data-pending`.
|
|
41
|
+
|
|
42
|
+
## Proxy `/api/auth` (TanStack Start + Convex)
|
|
43
|
+
|
|
44
|
+
À utiliser à la place du `handler` de `@convex-dev/better-auth` 0.12, qui rend un 500 sur tout refus 401 et transforme les redirections OAuth en JSON :
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// src/routes/api/auth/$.ts
|
|
48
|
+
const proxy = createAuthProxy(process.env.VITE_CONVEX_SITE_URL!)
|
|
49
|
+
// handlers: { GET: ({ request }) => proxy(request), POST: ({ request }) => proxy(request) }
|
|
50
|
+
|
|
51
|
+
// convex/http.ts
|
|
52
|
+
authComponent.registerRoutes(http, (ctx) => {
|
|
53
|
+
const auth = createAuth(ctx)
|
|
54
|
+
return { ...auth, handler: (request: Request) => auth.handler(restoreFetchMode(request)) }
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Garder une fonction Convex
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { requireOrgMember } from "@hgdc/auth/convex"
|
|
62
|
+
|
|
63
|
+
const { userId, role } = await requireOrgMember(ctx, organizationId, {
|
|
64
|
+
issuer: process.env.CONVEX_SITE_URL!, // émetteur des JWT du plugin convex
|
|
65
|
+
minRole: "admin",
|
|
66
|
+
load: async (ctx, { organizationId, userId }) => /* appartenance lue dans la base du produit */ null,
|
|
67
|
+
})
|
|
68
|
+
```
|
|
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.
|
|
71
|
+
|
|
72
|
+
Les organisations arrivent par la portée `organizations` : claim `organizations: [{ id, slug, name, role }]` de l'id_token et de userinfo.
|
|
6
73
|
|
|
7
74
|
Tous droits réservés, Hugo Gebel Développement Conseil.
|
package/dist/convex.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Auth, UserIdentity } from "convex/server";
|
|
2
|
+
import { ConvexError } from "convex/values";
|
|
3
|
+
import { type OrgRole } from "./index.ts";
|
|
4
|
+
export type AuthErrorCode = "UNAUTHENTICATED" | "FORBIDDEN";
|
|
5
|
+
/** Données d'une `ConvexError` levée par les gardes : `message` est affichable tel quel. */
|
|
6
|
+
export type AuthErrorData = {
|
|
7
|
+
code: AuthErrorCode;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
/** Lien entre une personne et une organisation, tel que le produit le stocke. */
|
|
11
|
+
export type Membership = {
|
|
12
|
+
organizationId: string;
|
|
13
|
+
userId: string;
|
|
14
|
+
/** Champ `role` de better-auth, éventuellement plusieurs rôles séparés par des virgules. */
|
|
15
|
+
role: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Lit l'appartenance de `userId` à `organizationId`, ou `null`. Account la lit dans les
|
|
19
|
+
* tables du plugin `organization` ; un produit, dans sa copie synchronisée depuis Account.
|
|
20
|
+
*/
|
|
21
|
+
export type MembershipLoader<Ctx> = (ctx: Ctx, args: {
|
|
22
|
+
organizationId: string;
|
|
23
|
+
userId: string;
|
|
24
|
+
}) => Promise<Membership | null>;
|
|
25
|
+
export type OrgAccess = {
|
|
26
|
+
identity: UserIdentity;
|
|
27
|
+
userId: string;
|
|
28
|
+
membership: Membership;
|
|
29
|
+
role: OrgRole;
|
|
30
|
+
};
|
|
31
|
+
/** Vrai si `error` vient d'une garde de ce module. */
|
|
32
|
+
export declare function isAuthError(error: unknown): error is ConvexError<AuthErrorData>;
|
|
33
|
+
/**
|
|
34
|
+
* Émetteur des jetons dont le `subject` désigne un utilisateur du produit. Avec le plugin
|
|
35
|
+
* `convex` de better-auth : `process.env.CONVEX_SITE_URL`. Un `subject` n'est unique que chez
|
|
36
|
+
* son émetteur : sans ce contrôle, un autre fournisseur accepté par Convex pourrait présenter
|
|
37
|
+
* le `subject` d'un membre et prendre ses droits. Un seul émetteur : l'appartenance est
|
|
38
|
+
* cherchée par `subject`, qui ne distingue pas deux fournisseurs.
|
|
39
|
+
*/
|
|
40
|
+
export type IssuerOption = {
|
|
41
|
+
issuer: string;
|
|
42
|
+
};
|
|
43
|
+
/** Identité de la personne connectée, émise par `issuer`, ou erreur `UNAUTHENTICATED`. */
|
|
44
|
+
export declare function requireIdentity(ctx: {
|
|
45
|
+
auth: Auth;
|
|
46
|
+
}, options: IssuerOption): Promise<UserIdentity>;
|
|
47
|
+
/**
|
|
48
|
+
* Garde d'une fonction Convex publique sur une ressource d'organisation : à appeler
|
|
49
|
+
* avant toute lecture ou écriture. Exige une session de l'émetteur attendu, l'appartenance à l'organisation
|
|
50
|
+
* et au moins le rôle `minRole` (par défaut `member`).
|
|
51
|
+
*
|
|
52
|
+
* Une personne étrangère à l'organisation et un membre au rôle insuffisant reçoivent
|
|
53
|
+
* tous deux `FORBIDDEN`, avec des messages distincts.
|
|
54
|
+
*/
|
|
55
|
+
export declare function requireOrgMember<Ctx extends {
|
|
56
|
+
auth: Auth;
|
|
57
|
+
}>(ctx: Ctx, organizationId: string, options: IssuerOption & {
|
|
58
|
+
load: MembershipLoader<Ctx>;
|
|
59
|
+
minRole?: OrgRole;
|
|
60
|
+
}): Promise<OrgAccess>;
|
package/dist/convex.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ConvexError } from "convex/values";
|
|
2
|
+
import { highestRole, ORG_ROLES } from "./index.js";
|
|
3
|
+
function authError(code, message) {
|
|
4
|
+
return new ConvexError({ code, message });
|
|
5
|
+
}
|
|
6
|
+
/** Vrai si `error` vient d'une garde de ce module. */
|
|
7
|
+
export function isAuthError(error) {
|
|
8
|
+
if (!(error instanceof ConvexError))
|
|
9
|
+
return false;
|
|
10
|
+
const data = error.data;
|
|
11
|
+
return (typeof data === "object" &&
|
|
12
|
+
data !== null &&
|
|
13
|
+
"code" in data &&
|
|
14
|
+
(data.code === "UNAUTHENTICATED" || data.code === "FORBIDDEN"));
|
|
15
|
+
}
|
|
16
|
+
function normalizeIssuer(value) {
|
|
17
|
+
return value.replace(/\/+$/, "");
|
|
18
|
+
}
|
|
19
|
+
/** Identité de la personne connectée, émise par `issuer`, ou erreur `UNAUTHENTICATED`. */
|
|
20
|
+
export async function requireIdentity(ctx, options) {
|
|
21
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
22
|
+
if (!identity)
|
|
23
|
+
throw authError("UNAUTHENTICATED", "Connectez-vous pour continuer.");
|
|
24
|
+
if (!options.issuer)
|
|
25
|
+
throw new Error("requireIdentity : émetteur attendu manquant (CONVEX_SITE_URL ?).");
|
|
26
|
+
if (normalizeIssuer(identity.issuer) !== normalizeIssuer(options.issuer))
|
|
27
|
+
throw authError("UNAUTHENTICATED", "Connectez-vous pour continuer.");
|
|
28
|
+
return identity;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Garde d'une fonction Convex publique sur une ressource d'organisation : à appeler
|
|
32
|
+
* avant toute lecture ou écriture. Exige une session de l'émetteur attendu, l'appartenance à l'organisation
|
|
33
|
+
* et au moins le rôle `minRole` (par défaut `member`).
|
|
34
|
+
*
|
|
35
|
+
* Une personne étrangère à l'organisation et un membre au rôle insuffisant reçoivent
|
|
36
|
+
* tous deux `FORBIDDEN`, avec des messages distincts.
|
|
37
|
+
*/
|
|
38
|
+
export async function requireOrgMember(ctx, organizationId, options) {
|
|
39
|
+
const identity = await requireIdentity(ctx, options);
|
|
40
|
+
const userId = identity.subject;
|
|
41
|
+
const membership = await options.load(ctx, { organizationId, userId });
|
|
42
|
+
// Une copie mal synchronisée ne doit jamais ouvrir l'accès à la mauvaise personne.
|
|
43
|
+
if (!membership || membership.userId !== userId || membership.organizationId !== organizationId) {
|
|
44
|
+
throw authError("FORBIDDEN", "Vous n'avez pas accès à cette organisation.");
|
|
45
|
+
}
|
|
46
|
+
const role = highestRole(membership.role);
|
|
47
|
+
const minRole = options.minRole ?? "member";
|
|
48
|
+
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.");
|
|
50
|
+
}
|
|
51
|
+
return { identity, userId, membership, role };
|
|
52
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,40 @@
|
|
|
1
|
-
|
|
1
|
+
/** Identifiant du fournisseur dans `genericOAuth` et dans la table `account` des produits. */
|
|
2
|
+
export declare const HGDC_PROVIDER_ID = "hgdc";
|
|
3
|
+
/** Émetteur OIDC de HGDC Account (`iss` des jetons, base de la découverte). */
|
|
4
|
+
export declare const HGDC_ISSUER = "https://account.hgdc.ch/api/auth";
|
|
5
|
+
/** Portée qui ajoute les organisations de la personne et son rôle dans chacune. */
|
|
6
|
+
export declare const ORGANIZATIONS_SCOPE = "organizations";
|
|
7
|
+
/** Portées demandées par défaut : identité, adresse vérifiée, renouvellement et organisations. */
|
|
8
|
+
export declare const HGDC_SCOPES: readonly string[];
|
|
9
|
+
/** Rôles d'organisation, du plus faible au plus fort. */
|
|
10
|
+
export declare const ORG_ROLES: readonly ["member", "admin", "owner"];
|
|
11
|
+
export type OrgRole = (typeof ORG_ROLES)[number];
|
|
12
|
+
/**
|
|
13
|
+
* Rôle le plus fort d'un champ `role` de better-auth. Le plugin `organization` accepte
|
|
14
|
+
* plusieurs rôles séparés par des virgules (« admin,member ») ; un rôle inconnu ne
|
|
15
|
+
* donne aucun droit. `null` quand aucun rôle connu.
|
|
16
|
+
*/
|
|
17
|
+
export declare function highestRole(role: string): OrgRole | null;
|
|
18
|
+
/** Vrai si `role` atteint au moins `minimum`. */
|
|
19
|
+
export declare function hasRole(role: string, minimum: OrgRole): boolean;
|
|
20
|
+
export type HgdcOrganization = {
|
|
21
|
+
id: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
name: string;
|
|
24
|
+
role: OrgRole;
|
|
25
|
+
};
|
|
26
|
+
/** Profil d'une personne tel que HGDC Account le donne (id_token ou userinfo). */
|
|
27
|
+
export type HgdcProfile = {
|
|
28
|
+
sub: string;
|
|
29
|
+
email: string;
|
|
30
|
+
email_verified: true;
|
|
31
|
+
name: string;
|
|
32
|
+
picture?: string | null | undefined;
|
|
33
|
+
/** Présent seulement si la portée `organizations` a été accordée. */
|
|
34
|
+
organizations?: HgdcOrganization[] | undefined;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Valide les claims reçus de HGDC Account. Lève une erreur si un champ attendu manque ou si
|
|
38
|
+
* l'adresse n'est pas vérifiée : un tel profil ne doit jamais ouvrir de session.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseHgdcProfile(claims: unknown): HgdcProfile;
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,58 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Identifiant du fournisseur dans `genericOAuth` et dans la table `account` des produits. */
|
|
3
|
+
export const HGDC_PROVIDER_ID = "hgdc";
|
|
4
|
+
/** Émetteur OIDC de HGDC Account (`iss` des jetons, base de la découverte). */
|
|
5
|
+
export const HGDC_ISSUER = "https://account.hgdc.ch/api/auth";
|
|
6
|
+
/** Portée qui ajoute les organisations de la personne et son rôle dans chacune. */
|
|
7
|
+
export const ORGANIZATIONS_SCOPE = "organizations";
|
|
8
|
+
/** Portées demandées par défaut : identité, adresse vérifiée, renouvellement et organisations. */
|
|
9
|
+
export const HGDC_SCOPES = ["openid", "profile", "email", "offline_access", ORGANIZATIONS_SCOPE];
|
|
10
|
+
/** Rôles d'organisation, du plus faible au plus fort. */
|
|
11
|
+
export const ORG_ROLES = ["member", "admin", "owner"];
|
|
12
|
+
function isOrgRole(value) {
|
|
13
|
+
return ORG_ROLES.includes(value);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Rôle le plus fort d'un champ `role` de better-auth. Le plugin `organization` accepte
|
|
17
|
+
* plusieurs rôles séparés par des virgules (« admin,member ») ; un rôle inconnu ne
|
|
18
|
+
* donne aucun droit. `null` quand aucun rôle connu.
|
|
19
|
+
*/
|
|
20
|
+
export function highestRole(role) {
|
|
21
|
+
let best = null;
|
|
22
|
+
for (const part of role.split(",")) {
|
|
23
|
+
const name = part.trim();
|
|
24
|
+
if (isOrgRole(name) && (best === null || ORG_ROLES.indexOf(name) > ORG_ROLES.indexOf(best)))
|
|
25
|
+
best = name;
|
|
26
|
+
}
|
|
27
|
+
return best;
|
|
28
|
+
}
|
|
29
|
+
/** Vrai si `role` atteint au moins `minimum`. */
|
|
30
|
+
export function hasRole(role, minimum) {
|
|
31
|
+
const best = highestRole(role);
|
|
32
|
+
return best !== null && ORG_ROLES.indexOf(best) >= ORG_ROLES.indexOf(minimum);
|
|
33
|
+
}
|
|
34
|
+
const organizationClaim = z.object({
|
|
35
|
+
id: z.string().min(1),
|
|
36
|
+
slug: z.string().min(1),
|
|
37
|
+
name: z.string().min(1),
|
|
38
|
+
role: z.enum(ORG_ROLES),
|
|
39
|
+
});
|
|
40
|
+
const profileClaims = z.object({
|
|
41
|
+
sub: z.string().min(1),
|
|
42
|
+
email: z.email(),
|
|
43
|
+
// Une adresse non vérifiée ne doit jamais créer ni relier un compte (liaison par e-mail).
|
|
44
|
+
email_verified: z.literal(true),
|
|
45
|
+
name: z.string().min(1),
|
|
46
|
+
picture: z.url().nullish(),
|
|
47
|
+
organizations: z.array(organizationClaim).optional(),
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* Valide les claims reçus de HGDC Account. Lève une erreur si un champ attendu manque ou si
|
|
51
|
+
* l'adresse n'est pas vérifiée : un tel profil ne doit jamais ouvrir de session.
|
|
52
|
+
*/
|
|
53
|
+
export function parseHgdcProfile(claims) {
|
|
54
|
+
const result = profileClaims.safeParse(claims);
|
|
55
|
+
if (!result.success)
|
|
56
|
+
throw new Error(`Profil HGDC invalide : ${z.prettifyError(result.error)}`);
|
|
57
|
+
return result.data;
|
|
58
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, type ReactElement } from "react";
|
|
2
|
+
/** Ce que le bouton utilise du client better-auth (plugin `genericOAuthClient`). */
|
|
3
|
+
export type HgdcAuthClient = {
|
|
4
|
+
signIn: {
|
|
5
|
+
oauth2: (args: {
|
|
6
|
+
providerId: string;
|
|
7
|
+
callbackURL?: string;
|
|
8
|
+
errorCallbackURL?: string;
|
|
9
|
+
}) => Promise<{
|
|
10
|
+
error?: unknown;
|
|
11
|
+
} | null | undefined>;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type HgdcSignInButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "type" | "children"> & {
|
|
15
|
+
authClient: HgdcAuthClient;
|
|
16
|
+
/** Page d'arrivée après la connexion, `/` par défaut. */
|
|
17
|
+
callbackURL?: string;
|
|
18
|
+
/** Page d'arrivée si HGDC Account refuse ou si la personne annule. */
|
|
19
|
+
errorCallbackURL?: string;
|
|
20
|
+
/** Appelé quand la redirection n'a pas pu partir (réseau, configuration). */
|
|
21
|
+
onError?: (error: unknown) => void;
|
|
22
|
+
label?: string;
|
|
23
|
+
};
|
|
24
|
+
export type HgdcSignInOptions = {
|
|
25
|
+
callbackURL?: string;
|
|
26
|
+
errorCallbackURL?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Part vers HGDC Account. Renvoie `{ ok: true }` quand la redirection est lancée,
|
|
30
|
+
* sinon l'erreur du client ou du réseau, sans lever.
|
|
31
|
+
*/
|
|
32
|
+
export declare function startHgdcSignIn(authClient: HgdcAuthClient, { callbackURL, errorCallbackURL }?: HgdcSignInOptions): Promise<{
|
|
33
|
+
ok: true;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
error: unknown;
|
|
37
|
+
}>;
|
|
38
|
+
/** Monogramme HGDC, en `currentColor`. */
|
|
39
|
+
export declare function HgdcMark(props: {
|
|
40
|
+
className?: string;
|
|
41
|
+
}): ReactElement;
|
|
42
|
+
/**
|
|
43
|
+
* Bouton « Se connecter avec HGDC ». Sans style propre : le produit le met en forme par
|
|
44
|
+
* `className`, l'état d'attente est exposé par `aria-busy` et `data-pending`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function HgdcSignInButton({ authClient, callbackURL, errorCallbackURL, onError, label, disabled, ...rest }: HgdcSignInButtonProps): ReactElement;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { HGDC_PROVIDER_ID } from "./index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Part vers HGDC Account. Renvoie `{ ok: true }` quand la redirection est lancée,
|
|
6
|
+
* sinon l'erreur du client ou du réseau, sans lever.
|
|
7
|
+
*/
|
|
8
|
+
export async function startHgdcSignIn(authClient, { callbackURL = "/", errorCallbackURL } = {}) {
|
|
9
|
+
try {
|
|
10
|
+
const result = await authClient.signIn.oauth2({
|
|
11
|
+
providerId: HGDC_PROVIDER_ID,
|
|
12
|
+
callbackURL,
|
|
13
|
+
...(errorCallbackURL ? { errorCallbackURL } : {}),
|
|
14
|
+
});
|
|
15
|
+
return result?.error ? { ok: false, error: result.error } : { ok: true };
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
return { ok: false, error };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Monogramme HGDC, en `currentColor`. */
|
|
22
|
+
export function HgdcMark(props) {
|
|
23
|
+
return (_jsxs("svg", { viewBox: "0 0 24 24", width: "16", height: "16", fill: "currentColor", "aria-hidden": "true", ...props, children: [_jsx("rect", { x: "2.5", y: "8.5", width: "13", height: "7", rx: "2", transform: "rotate(45 9 12)" }), _jsx("rect", { x: "8.5", y: "8.5", width: "13", height: "7", rx: "2", transform: "rotate(45 15 12)", opacity: "0.6" })] }));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Bouton « Se connecter avec HGDC ». Sans style propre : le produit le met en forme par
|
|
27
|
+
* `className`, l'état d'attente est exposé par `aria-busy` et `data-pending`.
|
|
28
|
+
*/
|
|
29
|
+
export function HgdcSignInButton({ authClient, callbackURL = "/", errorCallbackURL, onError, label = "Se connecter avec HGDC", disabled, ...rest }) {
|
|
30
|
+
const [pending, setPending] = useState(false);
|
|
31
|
+
async function signIn() {
|
|
32
|
+
setPending(true);
|
|
33
|
+
const result = await startHgdcSignIn(authClient, {
|
|
34
|
+
callbackURL,
|
|
35
|
+
...(errorCallbackURL ? { errorCallbackURL } : {}),
|
|
36
|
+
});
|
|
37
|
+
// Succès : le navigateur part vers HGDC Account, le bouton reste en attente.
|
|
38
|
+
if (!result.ok) {
|
|
39
|
+
setPending(false);
|
|
40
|
+
onError?.(result.error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return (_jsxs("button", { ...rest, type: "button", disabled: disabled === true || pending, "aria-busy": pending, "data-pending": pending ? "" : undefined, onClick: () => void signIn(), children: [_jsx(HgdcMark, {}), _jsx("span", { children: pending ? "Redirection vers HGDC…" : label })] }));
|
|
44
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GenericOAuthConfig } from "better-auth/plugins/generic-oauth";
|
|
2
|
+
/** En-tête qui porte le `sec-fetch-mode` du navigateur à travers le proxy. */
|
|
3
|
+
export declare const FETCH_MODE_HEADER = "x-hgdc-fetch-mode";
|
|
4
|
+
/**
|
|
5
|
+
* Côté Convex, avant better-auth : remet le `sec-fetch-mode` d'origine. better-auth y lit si
|
|
6
|
+
* une réponse doit rediriger (navigation) ou rendre du JSON (`fetch`). Sans cela, toute
|
|
7
|
+
* navigation vers `/oauth2/authorize` passée par le proxy reçoit du JSON au lieu d'un 302.
|
|
8
|
+
* Sans risque : qui appelle Convex directement choisit déjà cet en-tête.
|
|
9
|
+
*/
|
|
10
|
+
export declare function restoreFetchMode(request: Request): Request;
|
|
11
|
+
/**
|
|
12
|
+
* Proxy de `/api/auth/*` vers better-auth sur Convex (`https://<deploiement>.convex.site`),
|
|
13
|
+
* à monter dans la route serveur d'un produit TanStack Start, avec `restoreFetchMode` côté Convex.
|
|
14
|
+
*
|
|
15
|
+
* Remplace le `handler` de @convex-dev/better-auth 0.12, qui a deux défauts :
|
|
16
|
+
* - le corps part en flux : sur une réponse 401, undici échoue (« expected non-null body
|
|
17
|
+
* source ») et le client reçoit un 500 au lieu du refus. Ici il part en mémoire ;
|
|
18
|
+
* - undici impose `sec-fetch-mode: cors` : le mode du navigateur voyage donc dans
|
|
19
|
+
* `x-hgdc-fetch-mode`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createAuthProxy(convexSiteUrl: string, fetchImpl?: typeof fetch): (request: Request) => Promise<Response>;
|
|
22
|
+
export type HgdcProviderOptions = {
|
|
23
|
+
/** Identifiant du client, remis par HGDC à l'enregistrement du produit. */
|
|
24
|
+
clientId: string;
|
|
25
|
+
/** Secret du client : variable d'environnement côté serveur, jamais dans le navigateur. */
|
|
26
|
+
clientSecret: string;
|
|
27
|
+
/** Émetteur, à changer seulement pour un Account de préproduction. */
|
|
28
|
+
issuer?: string;
|
|
29
|
+
/** Portées demandées, `HGDC_SCOPES` par défaut. */
|
|
30
|
+
scopes?: readonly string[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Configuration du fournisseur « HGDC » pour le plugin `genericOAuth` de better-auth :
|
|
34
|
+
* découverte OIDC, code d'autorisation avec PKCE, émetteur vérifié contre la confusion
|
|
35
|
+
* entre fournisseurs. Le profil reçu est validé avant de créer ou relier un compte.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* genericOAuth({ config: [hgdcProvider({ clientId, clientSecret })] })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function hgdcProvider(options: HgdcProviderOptions): GenericOAuthConfig;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { HGDC_ISSUER, HGDC_PROVIDER_ID, HGDC_SCOPES, parseHgdcProfile } from "./index.js";
|
|
2
|
+
/** En-tête qui porte le `sec-fetch-mode` du navigateur à travers le proxy. */
|
|
3
|
+
export const FETCH_MODE_HEADER = "x-hgdc-fetch-mode";
|
|
4
|
+
const FETCH_MODES = new Set(["navigate", "cors", "no-cors", "same-origin", "websocket"]);
|
|
5
|
+
/**
|
|
6
|
+
* Côté Convex, avant better-auth : remet le `sec-fetch-mode` d'origine. better-auth y lit si
|
|
7
|
+
* une réponse doit rediriger (navigation) ou rendre du JSON (`fetch`). Sans cela, toute
|
|
8
|
+
* navigation vers `/oauth2/authorize` passée par le proxy reçoit du JSON au lieu d'un 302.
|
|
9
|
+
* Sans risque : qui appelle Convex directement choisit déjà cet en-tête.
|
|
10
|
+
*/
|
|
11
|
+
export function restoreFetchMode(request) {
|
|
12
|
+
const mode = request.headers.get(FETCH_MODE_HEADER);
|
|
13
|
+
if (!mode || !FETCH_MODES.has(mode))
|
|
14
|
+
return request;
|
|
15
|
+
const headers = new Headers(request.headers);
|
|
16
|
+
headers.set("sec-fetch-mode", mode);
|
|
17
|
+
headers.delete(FETCH_MODE_HEADER);
|
|
18
|
+
return new Request(request, { headers });
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Proxy de `/api/auth/*` vers better-auth sur Convex (`https://<deploiement>.convex.site`),
|
|
22
|
+
* à monter dans la route serveur d'un produit TanStack Start, avec `restoreFetchMode` côté Convex.
|
|
23
|
+
*
|
|
24
|
+
* Remplace le `handler` de @convex-dev/better-auth 0.12, qui a deux défauts :
|
|
25
|
+
* - le corps part en flux : sur une réponse 401, undici échoue (« expected non-null body
|
|
26
|
+
* source ») et le client reçoit un 500 au lieu du refus. Ici il part en mémoire ;
|
|
27
|
+
* - undici impose `sec-fetch-mode: cors` : le mode du navigateur voyage donc dans
|
|
28
|
+
* `x-hgdc-fetch-mode`.
|
|
29
|
+
*/
|
|
30
|
+
export function createAuthProxy(convexSiteUrl, fetchImpl = fetch) {
|
|
31
|
+
const target = new URL(convexSiteUrl);
|
|
32
|
+
return async (request) => {
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
const headers = new Headers(request.headers);
|
|
35
|
+
for (const name of ["transfer-encoding", "content-length", "connection"])
|
|
36
|
+
headers.delete(name);
|
|
37
|
+
headers.set("accept-encoding", "application/json");
|
|
38
|
+
headers.set("host", target.host);
|
|
39
|
+
const proto = url.protocol.replace(/:$/, "");
|
|
40
|
+
headers.set("x-forwarded-host", url.host);
|
|
41
|
+
headers.set("x-forwarded-proto", proto);
|
|
42
|
+
headers.set("x-better-auth-forwarded-host", url.host);
|
|
43
|
+
headers.set("x-better-auth-forwarded-proto", proto);
|
|
44
|
+
const mode = request.headers.get("sec-fetch-mode");
|
|
45
|
+
if (mode)
|
|
46
|
+
headers.set(FETCH_MODE_HEADER, mode);
|
|
47
|
+
else
|
|
48
|
+
headers.delete(FETCH_MODE_HEADER);
|
|
49
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
50
|
+
return await fetchImpl(`${target.origin}${url.pathname}${url.search}`, {
|
|
51
|
+
method: request.method,
|
|
52
|
+
headers,
|
|
53
|
+
redirect: "manual",
|
|
54
|
+
...(hasBody ? { body: await request.arrayBuffer() } : {}),
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Configuration du fournisseur « HGDC » pour le plugin `genericOAuth` de better-auth :
|
|
60
|
+
* découverte OIDC, code d'autorisation avec PKCE, émetteur vérifié contre la confusion
|
|
61
|
+
* entre fournisseurs. Le profil reçu est validé avant de créer ou relier un compte.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* genericOAuth({ config: [hgdcProvider({ clientId, clientSecret })] })
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function hgdcProvider(options) {
|
|
68
|
+
if (!options.clientId || !options.clientSecret) {
|
|
69
|
+
throw new Error("HGDC Account : clientId et clientSecret sont requis (voir le runbook d'Account).");
|
|
70
|
+
}
|
|
71
|
+
const issuer = (options.issuer ?? HGDC_ISSUER).replace(/\/+$/, "");
|
|
72
|
+
return {
|
|
73
|
+
providerId: HGDC_PROVIDER_ID,
|
|
74
|
+
clientId: options.clientId,
|
|
75
|
+
clientSecret: options.clientSecret,
|
|
76
|
+
issuer,
|
|
77
|
+
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
|
78
|
+
requireIssuerValidation: true,
|
|
79
|
+
scopes: [...(options.scopes ?? HGDC_SCOPES)],
|
|
80
|
+
pkce: true,
|
|
81
|
+
// L'identifiant du compte reste `sub` (lu par better-auth) ; ici seulement l'utilisateur.
|
|
82
|
+
mapProfileToUser: (claims) => {
|
|
83
|
+
const profile = parseHgdcProfile(claims);
|
|
84
|
+
return {
|
|
85
|
+
email: profile.email,
|
|
86
|
+
emailVerified: profile.email_verified,
|
|
87
|
+
name: profile.name,
|
|
88
|
+
image: profile.picture ?? null,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Connexion HGDC : bouton « Se connecter avec HGDC », config better-auth et gardes Convex.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
+
"homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/auth#readme",
|
|
7
|
+
"bugs": "https://github.com/hugogebel-boop/hgdc-kit/security/advisories/new",
|
|
6
8
|
"type": "module",
|
|
7
9
|
"repository": {
|
|
8
10
|
"type": "git",
|
|
@@ -21,13 +23,48 @@
|
|
|
21
23
|
"types": "./dist/index.d.ts",
|
|
22
24
|
"default": "./dist/index.js"
|
|
23
25
|
},
|
|
26
|
+
"./convex": {
|
|
27
|
+
"types": "./dist/convex.d.ts",
|
|
28
|
+
"default": "./dist/convex.js"
|
|
29
|
+
},
|
|
30
|
+
"./server": {
|
|
31
|
+
"types": "./dist/server.d.ts",
|
|
32
|
+
"default": "./dist/server.js"
|
|
33
|
+
},
|
|
34
|
+
"./react": {
|
|
35
|
+
"types": "./dist/react.d.ts",
|
|
36
|
+
"default": "./dist/react.js"
|
|
37
|
+
},
|
|
24
38
|
"./package.json": "./package.json"
|
|
25
39
|
},
|
|
26
40
|
"scripts": {
|
|
27
41
|
"build": "tsc -p tsconfig.build.json"
|
|
28
42
|
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"zod": "^4.6.5"
|
|
45
|
+
},
|
|
29
46
|
"peerDependencies": {
|
|
30
|
-
"better-auth": "
|
|
31
|
-
"convex": "^1.46.0"
|
|
47
|
+
"better-auth": ">=1.6.16 <1.7.0",
|
|
48
|
+
"convex": "^1.46.0",
|
|
49
|
+
"react": "^19.0.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"better-auth": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"convex": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"react": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/react": "19.3.0",
|
|
64
|
+
"@types/react-dom": "19.3.0",
|
|
65
|
+
"better-auth": "1.6.16",
|
|
66
|
+
"convex": "1.46.0",
|
|
67
|
+
"react": "19.3.0",
|
|
68
|
+
"react-dom": "19.3.0"
|
|
32
69
|
}
|
|
33
70
|
}
|