@hgdc/auth 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,7 +109,8 @@ export const Route = createFileRoute("/auth/hgdc")({
109
109
 
110
110
  Le claim `organizations` n'est lu qu'à la création d'une session (étape 1 ci-dessous). Une organisation rejointe dans Account reste donc invisible dans un produit dont la session est ouverte, jusqu'à 7 jours. `refresh=1` repasse par Account même avec une session ouverte : la nouvelle session relit le claim, sans formulaire quand la session d'Account existe.
111
111
 
112
- - Barre et menu des produits (`@hgdc/ui/products`) : `https://<mot>.hgdc.ch/auth/hgdc?refresh=1&next=/`. Chaque passage d'un produit à l'autre resynchronise.
112
+ - Barre et menu des produits (`@hgdc/ui/products`) : `https://<mot>.hgdc.ch/auth/hgdc?refresh=background&next=/` (ADR 0016). Session du produit ouverte : redirection immédiate vers `next` et cookie `HGDC_ORG_SYNC_COOKIE` ; la racine du document (`FamilyLoaderRoot`) le consomme et relit les organisations dans un cadre caché sans script (`HGDC_BACKGROUND_REFRESH_PATH`, `frame=1`), qui revient sur une réponse vide (`HGDC_FRAME_DONE_PATH`). La nouvelle session recopie les organisations, les requêtes abonnées se mettent à jour. Sans session : connexion habituelle.
113
+ - Préchargement au survol (Speculation Rules, `prefetch` seulement) : l'entrée ne rend que la redirection d'une session ouverte ; tout ce qui démarrerait une connexion (`refresh=1`, `frame=1`, pas de session) répond 503 à une requête `Sec-Purpose: prefetch`.
113
114
  - Page d'une organisation inconnue du produit (ou dont la personne n'est pas membre localement) : une seule redirection vers `hgdcRefreshPath("/o/acme")` avant d'afficher « introuvable ». Au retour, `next` porte `?synced=1` : `hasSyncedMarker(location.search)` vrai, la page affiche « introuvable » au lieu de relancer. L'entrée elle-même ne relance jamais un `next` déjà marqué : pas de boucle.
114
115
 
115
116
  ```tsx
@@ -129,7 +130,7 @@ if (!organization && !hasSyncedMarker(location.searchStr))
129
130
  - L'ancienne session n'est pas révoquée : si Account ne répond pas, la personne garde son accès, et la session expire seule (`SESSION_TTL_SECONDS`).
130
131
  - Lien vers un produit avec relecture : `hgdcEntryUrl(origin, "/o/acme", { refresh: true })`.
131
132
 
132
- Décisions : ADR 0010, ADR 0013.
133
+ Décisions : ADR 0010, ADR 0013, ADR 0016.
133
134
 
134
135
  ## Session dans le `beforeLoad` de la racine
135
136
 
package/dist/index.d.ts CHANGED
@@ -25,6 +25,31 @@ export declare const HGDC_ENTRY_REFRESH_PARAM = "refresh";
25
25
  * viennent d'être relues et ne relance pas l'entrée (une seule resynchronisation par navigation).
26
26
  */
27
27
  export declare const HGDC_SYNCED_PARAM = "synced";
28
+ /**
29
+ * `refresh=background` sur l'entrée (barre des produits, ADR 0016) : avec une session du produit
30
+ * ouverte, l'entrée va droit à `next` et pose `HGDC_ORG_SYNC_COOKIE` ; la page d'arrivée relit les
31
+ * organisations en arrière-plan (`HGDC_BACKGROUND_REFRESH_PATH`). Sans session : connexion HGDC
32
+ * habituelle, qui lit déjà les organisations. Un produit plus ancien l'ignore (pas de relecture).
33
+ */
34
+ export declare const HGDC_ENTRY_REFRESH_BACKGROUND = "background";
35
+ /**
36
+ * Cookie du produit posé par l'entrée `refresh=background` : « relire les organisations ». Lu et
37
+ * effacé par la page d'arrivée (`@hgdc/ui/app-shell`). Lisible en JavaScript, il ne porte rien
38
+ * d'autre que `1` : le forger ne fait que lancer une relecture par Account.
39
+ */
40
+ export declare const HGDC_ORG_SYNC_COOKIE = "hgdc-org-sync";
41
+ /** Durée de vie du cookie `HGDC_ORG_SYNC_COOKIE`, en secondes. */
42
+ export declare const HGDC_ORG_SYNC_COOKIE_MAX_AGE = 60;
43
+ /**
44
+ * `frame=1` : relecture des organisations dans un cadre caché de la page (même origine). L'entrée
45
+ * repasse par Account comme `refresh=1`, et le retour se fait sur `HGDC_FRAME_DONE_PATH`, une
46
+ * réponse vide : jamais sur une page du produit, jamais sur un `next` reçu.
47
+ */
48
+ export declare const HGDC_ENTRY_FRAME_PARAM = "frame";
49
+ /** Fin de la relecture en arrière-plan : réponse vide de l'entrée, sans appel à better-auth. */
50
+ export declare const HGDC_FRAME_DONE_PATH: string;
51
+ /** Adresse du cadre caché qui relit les organisations (`frame=1`). */
52
+ export declare const HGDC_BACKGROUND_REFRESH_PATH: string;
28
53
  /**
29
54
  * Page d'arrivée sûre : un chemin du produit lui-même, jamais une autre origine. Refuse ce qu'un
30
55
  * navigateur lirait comme une adresse externe (`//hôte`, `/\hôte`, schéma), les caractères de
@@ -33,10 +58,12 @@ export declare const HGDC_SYNCED_PARAM = "synced";
33
58
  export declare function safeNextPath(value: string | null | undefined, fallback?: string): string;
34
59
  /**
35
60
  * Adresse de l'entrée commune d'un produit : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
36
- * Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée.
61
+ * Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée. `refresh: true`
62
+ * relit les organisations avant d'arriver (`refresh=1`) ; `refresh: "background"` arrive tout de
63
+ * suite si la session du produit est ouverte et relit en arrière-plan (ADR 0016).
37
64
  */
38
65
  export declare function hgdcEntryUrl(productOrigin: string, next?: string, options?: {
39
- refresh?: boolean;
66
+ refresh?: boolean | typeof HGDC_ENTRY_REFRESH_BACKGROUND;
40
67
  }): string;
41
68
  /**
42
69
  * Chemin relatif qui relit les organisations puis revient sur `next` :
package/dist/index.js CHANGED
@@ -26,6 +26,31 @@ export const HGDC_ENTRY_REFRESH_PARAM = "refresh";
26
26
  * viennent d'être relues et ne relance pas l'entrée (une seule resynchronisation par navigation).
27
27
  */
28
28
  export const HGDC_SYNCED_PARAM = "synced";
29
+ /**
30
+ * `refresh=background` sur l'entrée (barre des produits, ADR 0016) : avec une session du produit
31
+ * ouverte, l'entrée va droit à `next` et pose `HGDC_ORG_SYNC_COOKIE` ; la page d'arrivée relit les
32
+ * organisations en arrière-plan (`HGDC_BACKGROUND_REFRESH_PATH`). Sans session : connexion HGDC
33
+ * habituelle, qui lit déjà les organisations. Un produit plus ancien l'ignore (pas de relecture).
34
+ */
35
+ export const HGDC_ENTRY_REFRESH_BACKGROUND = "background";
36
+ /**
37
+ * Cookie du produit posé par l'entrée `refresh=background` : « relire les organisations ». Lu et
38
+ * effacé par la page d'arrivée (`@hgdc/ui/app-shell`). Lisible en JavaScript, il ne porte rien
39
+ * d'autre que `1` : le forger ne fait que lancer une relecture par Account.
40
+ */
41
+ export const HGDC_ORG_SYNC_COOKIE = "hgdc-org-sync";
42
+ /** Durée de vie du cookie `HGDC_ORG_SYNC_COOKIE`, en secondes. */
43
+ export const HGDC_ORG_SYNC_COOKIE_MAX_AGE = 60;
44
+ /**
45
+ * `frame=1` : relecture des organisations dans un cadre caché de la page (même origine). L'entrée
46
+ * repasse par Account comme `refresh=1`, et le retour se fait sur `HGDC_FRAME_DONE_PATH`, une
47
+ * réponse vide : jamais sur une page du produit, jamais sur un `next` reçu.
48
+ */
49
+ export const HGDC_ENTRY_FRAME_PARAM = "frame";
50
+ /** Fin de la relecture en arrière-plan : réponse vide de l'entrée, sans appel à better-auth. */
51
+ export const HGDC_FRAME_DONE_PATH = `${HGDC_ENTRY_PATH}?${HGDC_ENTRY_FRAME_PARAM}=done`;
52
+ /** Adresse du cadre caché qui relit les organisations (`frame=1`). */
53
+ export const HGDC_BACKGROUND_REFRESH_PATH = `${HGDC_ENTRY_PATH}?${HGDC_ENTRY_REFRESH_PARAM}=1&${HGDC_ENTRY_FRAME_PARAM}=1`;
29
54
  const NEXT_MAX_LENGTH = 2048;
30
55
  const NEXT_BASE = "https://next.invalid";
31
56
  /**
@@ -58,13 +83,15 @@ export function safeNextPath(value, fallback = "/") {
58
83
  }
59
84
  /**
60
85
  * Adresse de l'entrée commune d'un produit : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
61
- * Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée.
86
+ * Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée. `refresh: true`
87
+ * relit les organisations avant d'arriver (`refresh=1`) ; `refresh: "background"` arrive tout de
88
+ * suite si la session du produit est ouverte et relit en arrière-plan (ADR 0016).
62
89
  */
63
90
  export function hgdcEntryUrl(productOrigin, next, options = {}) {
64
91
  const url = new URL(HGDC_ENTRY_PATH, new URL(productOrigin).origin);
65
92
  const path = next === undefined ? "/" : safeNextPath(next);
66
93
  if (options.refresh)
67
- url.searchParams.set(HGDC_ENTRY_REFRESH_PARAM, "1");
94
+ url.searchParams.set(HGDC_ENTRY_REFRESH_PARAM, options.refresh === HGDC_ENTRY_REFRESH_BACKGROUND ? HGDC_ENTRY_REFRESH_BACKGROUND : "1");
68
95
  if (path !== "/" || options.refresh)
69
96
  url.searchParams.set(HGDC_ENTRY_NEXT_PARAM, path);
70
97
  return url.toString();
package/dist/react.d.ts CHANGED
@@ -36,13 +36,17 @@ export declare function startHgdcSignIn(authClient: HgdcAuthClient, { callbackUR
36
36
  error: unknown;
37
37
  }>;
38
38
  /**
39
- * Le H tricolore de la famille, copie exacte de `@hgdc/ui/family/hfamily-icon.svg` (un test le
40
- * vérifie) : `@hgdc/auth` ne dépend pas de `@hgdc/ui`.
39
+ * Le H « Double » de la H Collection, copies exactes de `@hgdc/ui/family/hcollection-icon.svg` (fond
40
+ * clair) et `hcollection-icon-dark.svg` (fond sombre) ; un test le vérifie : `@hgdc/auth` ne dépend
41
+ * pas de `@hgdc/ui`.
41
42
  */
42
43
  export declare const HGDC_MARK_SVG: string;
44
+ /** Le même H, pour un fond sombre. */
45
+ export declare const HGDC_MARK_DARK_SVG: string;
43
46
  /**
44
- * Le H tricolore de la famille, en image : ses dégradés gardent leurs identifiants pour eux, deux
45
- * marques dans une page ne se marchent pas dessus. Couleurs pleines, jamais recolorées.
47
+ * Le H de la H Collection, en image : ses dégradés gardent leurs identifiants pour eux, deux marques
48
+ * dans une page ne se marchent pas dessus. Les deux variantes sont rendues ; les styles de
49
+ * `@hgdc/ui` (`data-mark-theme`) montrent celle du thème. Couleurs pleines, jamais recolorées.
46
50
  */
47
51
  export declare function HgdcMark({ className }: {
48
52
  className?: string;
package/dist/react.js CHANGED
@@ -19,16 +19,21 @@ export async function startHgdcSignIn(authClient, { callbackURL = "/", errorCall
19
19
  }
20
20
  }
21
21
  /**
22
- * Le H tricolore de la famille, copie exacte de `@hgdc/ui/family/hfamily-icon.svg` (un test le
23
- * vérifie) : `@hgdc/auth` ne dépend pas de `@hgdc/ui`.
22
+ * Le H « Double » de la H Collection, copies exactes de `@hgdc/ui/family/hcollection-icon.svg` (fond
23
+ * clair) et `hcollection-icon-dark.svg` (fond sombre) ; un test le vérifie : `@hgdc/auth` ne dépend
24
+ * pas de `@hgdc/ui`.
24
25
  */
25
- export const HGDC_MARK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><linearGradient id="l" x1="0" y1="24" x2="0" y2="232" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2c78fd"/><stop offset="1" stop-color="#7343fe"/></linearGradient><linearGradient id="r" x1="0" y1="24" x2="0" y2="232" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fe5292"/><stop offset="1" stop-color="#ff9a46"/></linearGradient><linearGradient id="b" x1="64" y1="0" x2="192" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8145ff"/><stop offset="1" stop-color="#fe5292"/></linearGradient></defs><rect x="43" y="24" width="57" height="208" rx="18" fill="url(#l)"/><rect x="156" y="24" width="57" height="208" rx="18" fill="url(#r)"/><rect x="67" y="107" width="122" height="43" rx="14" fill="url(#b)"/></svg>';
26
+ export const HGDC_MARK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="9 9 46 46"><defs><linearGradient id="hl" x1="0" y1="11.5" x2="0" y2="47.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2c78fd"/><stop offset="1" stop-color="#7343fe"/></linearGradient><linearGradient id="hr" x1="0" y1="11.5" x2="0" y2="47.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fe5292"/><stop offset="1" stop-color="#ff9a46"/></linearGradient><linearGradient id="hf" x1="24.5" y1="0" x2="34.5" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></linearGradient><mask id="hm" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><rect x="24" y="24" width="11" height="11" fill="url(#hf)"/></mask><g id="h"><path d="M24 25H35V34H24Z" fill="url(#hr)"/><path d="M24 25H35V34H24Z" fill="url(#hl)" mask="url(#hm)"/><path d="M17.5 11.5H21.5A3 3 0 0 1 24.5 14.5V44.5A3 3 0 0 1 21.5 47.5H17.5A3 3 0 0 1 14.5 44.5V14.5A3 3 0 0 1 17.5 11.5Z" fill="url(#hl)"/><path d="M37.5 11.5H41.5A3 3 0 0 1 44.5 14.5V44.5A3 3 0 0 1 41.5 47.5H37.5A3 3 0 0 1 34.5 44.5V14.5A3 3 0 0 1 37.5 11.5Z" fill="url(#hr)"/></g></defs><use href="#h" transform="translate(6 6)" opacity="0.35"/><use href="#h"/></svg>';
27
+ /** Le même H, pour un fond sombre. */
28
+ export const HGDC_MARK_DARK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="9 9 46 46"><defs><linearGradient id="hl" x1="0" y1="11.5" x2="0" y2="47.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#6aa0ff"/><stop offset="1" stop-color="#9a7bff"/></linearGradient><linearGradient id="hr" x1="0" y1="11.5" x2="0" y2="47.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff7aa8"/><stop offset="1" stop-color="#ffb070"/></linearGradient><linearGradient id="hf" x1="24.5" y1="0" x2="34.5" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></linearGradient><mask id="hm" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><rect x="24" y="24" width="11" height="11" fill="url(#hf)"/></mask><g id="h"><path d="M24 25H35V34H24Z" fill="url(#hr)"/><path d="M24 25H35V34H24Z" fill="url(#hl)" mask="url(#hm)"/><path d="M17.5 11.5H21.5A3 3 0 0 1 24.5 14.5V44.5A3 3 0 0 1 21.5 47.5H17.5A3 3 0 0 1 14.5 44.5V14.5A3 3 0 0 1 17.5 11.5Z" fill="url(#hl)"/><path d="M37.5 11.5H41.5A3 3 0 0 1 44.5 14.5V44.5A3 3 0 0 1 41.5 47.5H37.5A3 3 0 0 1 34.5 44.5V14.5A3 3 0 0 1 37.5 11.5Z" fill="url(#hr)"/></g></defs><use href="#h" transform="translate(6 6)" opacity="0.5"/><use href="#h"/></svg>';
29
+ const markSrc = (svg) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
26
30
  /**
27
- * Le H tricolore de la famille, en image : ses dégradés gardent leurs identifiants pour eux, deux
28
- * marques dans une page ne se marchent pas dessus. Couleurs pleines, jamais recolorées.
31
+ * Le H de la H Collection, en image : ses dégradés gardent leurs identifiants pour eux, deux marques
32
+ * dans une page ne se marchent pas dessus. Les deux variantes sont rendues ; les styles de
33
+ * `@hgdc/ui` (`data-mark-theme`) montrent celle du thème. Couleurs pleines, jamais recolorées.
29
34
  */
30
35
  export function HgdcMark({ className }) {
31
- return (_jsx("img", { src: `data:image/svg+xml,${encodeURIComponent(HGDC_MARK_SVG)}`, alt: "", "aria-hidden": "true", width: 16, height: 16, draggable: false, className: className }));
36
+ return (_jsx("span", { "data-slot": "hgdc-mark", "aria-hidden": "true", className: className, style: { display: "inline-block", flexShrink: 0 }, children: ["light", "dark"].map((theme) => (_jsx("img", { src: markSrc(theme === "light" ? HGDC_MARK_SVG : HGDC_MARK_DARK_SVG), alt: "", width: 16, height: 16, draggable: false, "data-mark-theme": theme, style: { width: "100%", height: "100%" } }, theme))) }));
32
37
  }
33
38
  /**
34
39
  * Bouton « Se connecter avec HGDC ». Sans style propre : le produit le met en forme par
package/dist/server.d.ts CHANGED
@@ -45,6 +45,12 @@ export type HgdcEntryOptions = {
45
45
  * page ne relance pas l'entrée. Un `next` qui porte déjà `synced=1` ne relance rien : pas de
46
46
  * boucle. L'ancienne session n'est pas révoquée : si Account ne répond pas, la personne garde
47
47
  * son accès, et la session expire seule (`SESSION_TTL_SECONDS`).
48
+ * - `refresh=background` (barre des produits, ADR 0016) : session du produit ouverte, droit à
49
+ * `next` avec le cookie `HGDC_ORG_SYNC_COOKIE` ; la page d'arrivée relit les organisations dans
50
+ * un cadre caché (`frame=1`, retour sur `HGDC_FRAME_DONE_PATH`). Sans session, comme sans
51
+ * paramètre.
52
+ * - Préchargement (`Sec-Purpose: prefetch`) : seule la redirection d'une session ouverte est
53
+ * rendue ; tout ce qui démarrerait une connexion répond 503, que le navigateur jette.
48
54
  *
49
55
  * `next` est filtré par `safeNextPath` (chemin du produit seulement, pas de redirection ouverte),
50
56
  * puis better-auth le revérifie contre ses `trustedOrigins`.
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { HGDC_ENTRY_NEXT_PARAM, HGDC_ENTRY_REFRESH_PARAM, HGDC_ISSUER, HGDC_PROVIDER_ID, HGDC_SCOPES, hasSyncedMarker, parseHgdcProfile, safeNextPath, withSyncedMarker, } from "./index.js";
1
+ import { HGDC_ENTRY_FRAME_PARAM, HGDC_ENTRY_NEXT_PARAM, HGDC_ENTRY_REFRESH_BACKGROUND, HGDC_ENTRY_REFRESH_PARAM, HGDC_FRAME_DONE_PATH, HGDC_ISSUER, HGDC_PROVIDER_ID, HGDC_ORG_SYNC_COOKIE, HGDC_ORG_SYNC_COOKIE_MAX_AGE, HGDC_SCOPES, hasSyncedMarker, parseHgdcProfile, safeNextPath, withSyncedMarker, } 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"]);
@@ -121,6 +121,12 @@ function authorizationUrl(body) {
121
121
  * page ne relance pas l'entrée. Un `next` qui porte déjà `synced=1` ne relance rien : pas de
122
122
  * boucle. L'ancienne session n'est pas révoquée : si Account ne répond pas, la personne garde
123
123
  * son accès, et la session expire seule (`SESSION_TTL_SECONDS`).
124
+ * - `refresh=background` (barre des produits, ADR 0016) : session du produit ouverte, droit à
125
+ * `next` avec le cookie `HGDC_ORG_SYNC_COOKIE` ; la page d'arrivée relit les organisations dans
126
+ * un cadre caché (`frame=1`, retour sur `HGDC_FRAME_DONE_PATH`). Sans session, comme sans
127
+ * paramètre.
128
+ * - Préchargement (`Sec-Purpose: prefetch`) : seule la redirection d'une session ouverte est
129
+ * rendue ; tout ce qui démarrerait une connexion répond 503, que le navigateur jette.
124
130
  *
125
131
  * `next` est filtré par `safeNextPath` (chemin du produit seulement, pas de redirection ouverte),
126
132
  * puis better-auth le revérifie contre ses `trustedOrigins`.
@@ -140,35 +146,75 @@ export function createHgdcEntry(options) {
140
146
  const basePath = (options.basePath ?? "/api/auth").replace(/\/+$/, "");
141
147
  return async (request) => {
142
148
  const url = new URL(request.url);
149
+ const frame = url.searchParams.get(HGDC_ENTRY_FRAME_PARAM);
150
+ // Fin d'une relecture dans le cadre caché : rien à lire, rien à appeler.
151
+ if (frame === "done")
152
+ return frameDone();
153
+ const inFrame = frame === "1";
154
+ const speculative = isSpeculative(request);
143
155
  const next = safeNextPath(url.searchParams.get(HGDC_ENTRY_NEXT_PARAM));
156
+ const refreshParam = url.searchParams.get(HGDC_ENTRY_REFRESH_PARAM);
144
157
  // Un `next` déjà marqué revient d'une resynchronisation : on n'en relance pas une autre.
145
- const refresh = url.searchParams.get(HGDC_ENTRY_REFRESH_PARAM) === "1" && !hasSyncedMarker(next);
146
- const callbackURL = refresh ? withSyncedMarker(next) : next;
158
+ const refresh = inFrame || (refreshParam === "1" && !hasSyncedMarker(next));
159
+ const background = !refresh && refreshParam === HGDC_ENTRY_REFRESH_BACKGROUND;
160
+ // Le cadre revient toujours sur la réponse vide, jamais sur une page reçue en paramètre.
161
+ const callbackURL = inFrame ? HGDC_FRAME_DONE_PATH : refresh ? withSyncedMarker(next) : next;
162
+ const errorURL = inFrame ? HGDC_FRAME_DONE_PATH : errorCallbackURL;
163
+ // Un préchargement ne démarre jamais de connexion : état OAuth, code d'Account et session
164
+ // créés pour un survol sans clic. Il ne sert que si la session du produit est déjà ouverte.
165
+ if (refresh && speculative)
166
+ return notForSpeculation();
147
167
  const cookies = [];
148
168
  try {
149
169
  if (!refresh && request.headers.has("cookie")) {
170
+ // Session vérifiée par better-auth (base du produit), pas seulement la présence du cookie.
150
171
  const session = await options.proxy(new Request(`${url.origin}${basePath}/get-session`, { headers: internalHeaders(request, url.origin) }));
151
172
  cookies.push(...setCookies(session));
152
173
  if (session.ok && hasSessionBody(await readJson(session)))
153
- return redirectTo(next, cookies);
174
+ return redirectTo(next, background ? [...cookies, orgSyncCookie(url)] : cookies);
154
175
  }
176
+ if (speculative)
177
+ return notForSpeculation();
155
178
  const headers = internalHeaders(request, url.origin);
156
179
  headers.set("content-type", "application/json");
157
180
  const signIn = await options.proxy(new Request(`${url.origin}${basePath}/sign-in/oauth2`, {
158
181
  method: "POST",
159
182
  headers,
160
- body: JSON.stringify({ providerId: HGDC_PROVIDER_ID, callbackURL, errorCallbackURL }),
183
+ body: JSON.stringify({ providerId: HGDC_PROVIDER_ID, callbackURL, errorCallbackURL: errorURL }),
161
184
  }));
162
185
  const target = signIn.ok ? authorizationUrl(await readJson(signIn)) : null;
163
186
  if (!target)
164
- return redirectTo(errorCallbackURL, cookies);
187
+ return redirectTo(errorURL, cookies);
165
188
  return redirectTo(target, [...cookies, ...setCookies(signIn)]);
166
189
  }
167
190
  catch {
168
- return redirectTo(errorCallbackURL, cookies);
191
+ return speculative ? notForSpeculation() : redirectTo(errorURL, cookies);
169
192
  }
170
193
  };
171
194
  }
195
+ /** Préchargement ou prérendu (Speculation Rules) : `Sec-Purpose: prefetch`, `prefetch;prerender`. */
196
+ function isSpeculative(request) {
197
+ const purpose = request.headers.get("sec-purpose") ?? request.headers.get("purpose") ?? "";
198
+ return /\b(prefetch|prerender)\b/i.test(purpose);
199
+ }
200
+ /** Refus d'un préchargement : le navigateur jette une réponse qui n'est pas 2xx, le clic repart. */
201
+ function notForSpeculation() {
202
+ return new Response(null, { status: 503, headers: { "cache-control": "no-store", "retry-after": "0" } });
203
+ }
204
+ function frameDone() {
205
+ return new Response(null, {
206
+ status: 200,
207
+ headers: {
208
+ "content-type": "text/plain; charset=utf-8",
209
+ "cache-control": "no-store",
210
+ "referrer-policy": "no-referrer",
211
+ },
212
+ });
213
+ }
214
+ function orgSyncCookie(url) {
215
+ const secure = url.protocol === "https:" ? "; Secure" : "";
216
+ return `${HGDC_ORG_SYNC_COOKIE}=1; Path=/; Max-Age=${HGDC_ORG_SYNC_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
217
+ }
172
218
  /**
173
219
  * Configuration du fournisseur « HGDC » pour le plugin `genericOAuth` de better-auth :
174
220
  * 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.12.0",
3
+ "version": "0.14.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",