@hgdc/auth 0.4.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 +31 -6
- package/dist/index.d.ts +19 -0
- package/dist/index.js +49 -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`, `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`
|
|
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
|
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];
|
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) {
|
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",
|