@hgdc/auth 0.6.2 → 0.7.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
@@ -104,7 +104,26 @@ export const Route = createFileRoute("/auth/hgdc")({
104
104
  - `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.
105
105
  - Lien vers un produit depuis ailleurs : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
106
106
 
107
- Décision : ADR 0010.
107
+ ### Relire les organisations : `refresh=1`
108
+
109
+ 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.
110
+
111
+ - 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
+ - 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.
113
+
114
+ ```ts
115
+ import { hasSyncedMarker, hgdcRefreshPath } from "@hgdc/auth"
116
+
117
+ if (org === null && !hasSyncedMarker(window.location.search)) {
118
+ window.location.replace(hgdcRefreshPath(`/o/${slug}`))
119
+ return null
120
+ }
121
+ ```
122
+
123
+ - 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`).
124
+ - Lien vers un produit avec relecture : `hgdcEntryUrl(origin, "/o/acme", { refresh: true })`.
125
+
126
+ Décisions : ADR 0010, ADR 0013.
108
127
 
109
128
  ## Garder une fonction Convex
110
129
 
package/dist/index.d.ts CHANGED
@@ -14,6 +14,17 @@ export declare const HGDC_SCOPES: readonly string[];
14
14
  export declare const HGDC_ENTRY_PATH = "/auth/hgdc";
15
15
  /** Paramètre de l'entrée qui porte la page d'arrivée. */
16
16
  export declare const HGDC_ENTRY_NEXT_PARAM = "next";
17
+ /**
18
+ * `refresh=1` sur l'entrée : repasser par Account même si la session du produit est ouverte. Le
19
+ * claim `organizations` n'est lu qu'à la création d'une session ; une organisation rejointe dans
20
+ * Account n'apparaît dans le produit qu'à la session suivante. La nouvelle session relit le claim.
21
+ */
22
+ export declare const HGDC_ENTRY_REFRESH_PARAM = "refresh";
23
+ /**
24
+ * Ajouté à `next` au retour d'un `refresh` (`?synced=1`) : la page sait que les organisations
25
+ * viennent d'être relues et ne relance pas l'entrée (une seule resynchronisation par navigation).
26
+ */
27
+ export declare const HGDC_SYNCED_PARAM = "synced";
17
28
  /**
18
29
  * Page d'arrivée sûre : un chemin du produit lui-même, jamais une autre origine. Refuse ce qu'un
19
30
  * navigateur lirait comme une adresse externe (`//hôte`, `/\hôte`, schéma), les caractères de
@@ -24,7 +35,24 @@ export declare function safeNextPath(value: string | null | undefined, fallback?
24
35
  * Adresse de l'entrée commune d'un produit : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
25
36
  * Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée.
26
37
  */
27
- export declare function hgdcEntryUrl(productOrigin: string, next?: string): string;
38
+ export declare function hgdcEntryUrl(productOrigin: string, next?: string, options?: {
39
+ refresh?: boolean;
40
+ }): string;
41
+ /**
42
+ * Chemin relatif qui relit les organisations puis revient sur `next` :
43
+ * `/auth/hgdc?refresh=1&next=/o/acme`. Pour une page d'organisation inconnue du produit, avant
44
+ * d'afficher « introuvable », si `hasSyncedMarker` est faux.
45
+ */
46
+ export declare function hgdcRefreshPath(next: string): string;
47
+ /**
48
+ * Vrai si la page arrive d'une resynchronisation (`?synced=1`) : ne pas relancer l'entrée.
49
+ * Accepte une requête (`?a=1`), une adresse ou un chemin complet, ou des `URLSearchParams`.
50
+ */
51
+ export declare function hasSyncedMarker(search: string | URLSearchParams): boolean;
52
+ /** `path` sans le marqueur `synced`, le reste de la requête et le fragment gardés. */
53
+ export declare function withoutSyncedMarker(path: string): string;
54
+ /** `path` avec le marqueur `synced=1`, avant le fragment. */
55
+ export declare function withSyncedMarker(path: string): string;
28
56
  /** Rôles d'organisation, du plus faible au plus fort. */
29
57
  export declare const ORG_ROLES: readonly ["member", "admin", "owner"];
30
58
  export type OrgRole = (typeof ORG_ROLES)[number];
package/dist/index.js CHANGED
@@ -15,6 +15,17 @@ export const HGDC_SCOPES = ["openid", "profile", "email", "offline_access", ORGA
15
15
  export const HGDC_ENTRY_PATH = "/auth/hgdc";
16
16
  /** Paramètre de l'entrée qui porte la page d'arrivée. */
17
17
  export const HGDC_ENTRY_NEXT_PARAM = "next";
18
+ /**
19
+ * `refresh=1` sur l'entrée : repasser par Account même si la session du produit est ouverte. Le
20
+ * claim `organizations` n'est lu qu'à la création d'une session ; une organisation rejointe dans
21
+ * Account n'apparaît dans le produit qu'à la session suivante. La nouvelle session relit le claim.
22
+ */
23
+ export const HGDC_ENTRY_REFRESH_PARAM = "refresh";
24
+ /**
25
+ * Ajouté à `next` au retour d'un `refresh` (`?synced=1`) : la page sait que les organisations
26
+ * viennent d'être relues et ne relance pas l'entrée (une seule resynchronisation par navigation).
27
+ */
28
+ export const HGDC_SYNCED_PARAM = "synced";
18
29
  const NEXT_MAX_LENGTH = 2048;
19
30
  const NEXT_BASE = "https://next.invalid";
20
31
  /**
@@ -49,13 +60,61 @@ export function safeNextPath(value, fallback = "/") {
49
60
  * Adresse de l'entrée commune d'un produit : `hgdcEntryUrl("https://mail.hgdc.ch", "/o/acme")`.
50
61
  * Sans `next` (ou `next` refusé), le produit ouvre sa page d'accueil connectée.
51
62
  */
52
- export function hgdcEntryUrl(productOrigin, next) {
63
+ export function hgdcEntryUrl(productOrigin, next, options = {}) {
53
64
  const url = new URL(HGDC_ENTRY_PATH, new URL(productOrigin).origin);
54
65
  const path = next === undefined ? "/" : safeNextPath(next);
55
- if (path !== "/")
66
+ if (options.refresh)
67
+ url.searchParams.set(HGDC_ENTRY_REFRESH_PARAM, "1");
68
+ if (path !== "/" || options.refresh)
56
69
  url.searchParams.set(HGDC_ENTRY_NEXT_PARAM, path);
57
70
  return url.toString();
58
71
  }
72
+ /**
73
+ * Chemin relatif qui relit les organisations puis revient sur `next` :
74
+ * `/auth/hgdc?refresh=1&next=/o/acme`. Pour une page d'organisation inconnue du produit, avant
75
+ * d'afficher « introuvable », si `hasSyncedMarker` est faux.
76
+ */
77
+ export function hgdcRefreshPath(next) {
78
+ const params = new URLSearchParams({
79
+ [HGDC_ENTRY_REFRESH_PARAM]: "1",
80
+ [HGDC_ENTRY_NEXT_PARAM]: safeNextPath(withoutSyncedMarker(next)),
81
+ });
82
+ return `${HGDC_ENTRY_PATH}?${params.toString()}`;
83
+ }
84
+ function searchParamsOf(search) {
85
+ if (typeof search !== "string")
86
+ return search;
87
+ const query = search.includes("?") ? search.slice(search.indexOf("?") + 1).split("#")[0] : search;
88
+ return new URLSearchParams(query);
89
+ }
90
+ /**
91
+ * Vrai si la page arrive d'une resynchronisation (`?synced=1`) : ne pas relancer l'entrée.
92
+ * Accepte une requête (`?a=1`), une adresse ou un chemin complet, ou des `URLSearchParams`.
93
+ */
94
+ export function hasSyncedMarker(search) {
95
+ return searchParamsOf(search).get(HGDC_SYNCED_PARAM) === "1";
96
+ }
97
+ /** `path` sans le marqueur `synced`, le reste de la requête et le fragment gardés. */
98
+ export function withoutSyncedMarker(path) {
99
+ const hashAt = path.indexOf("#");
100
+ const hash = hashAt === -1 ? "" : path.slice(hashAt);
101
+ const beforeHash = hashAt === -1 ? path : path.slice(0, hashAt);
102
+ const queryAt = beforeHash.indexOf("?");
103
+ if (queryAt === -1)
104
+ return path;
105
+ const params = new URLSearchParams(beforeHash.slice(queryAt + 1));
106
+ params.delete(HGDC_SYNCED_PARAM);
107
+ const query = params.toString();
108
+ return `${beforeHash.slice(0, queryAt)}${query ? `?${query}` : ""}${hash}`;
109
+ }
110
+ /** `path` avec le marqueur `synced=1`, avant le fragment. */
111
+ export function withSyncedMarker(path) {
112
+ const clean = withoutSyncedMarker(path);
113
+ const hashAt = clean.indexOf("#");
114
+ const hash = hashAt === -1 ? "" : clean.slice(hashAt);
115
+ const beforeHash = hashAt === -1 ? clean : clean.slice(0, hashAt);
116
+ return `${beforeHash}${beforeHash.includes("?") ? "&" : "?"}${HGDC_SYNCED_PARAM}=1${hash}`;
117
+ }
59
118
  /** Rôles d'organisation, du plus faible au plus fort. */
60
119
  export const ORG_ROLES = ["member", "admin", "owner"];
61
120
  function isOrgRole(value) {
package/dist/server.d.ts CHANGED
@@ -39,6 +39,12 @@ export type HgdcEntryOptions = {
39
39
  * - Sinon : la connexion HGDC part aussitôt (même appel que `HgdcSignInButton`, fait par le
40
40
  * serveur), retour vers `next`. Si la session d'Account est ouverte et le produit déjà
41
41
  * autorisé, Account renvoie sans formulaire : trois redirections, aucun clic.
42
+ * - `refresh=1` (`HGDC_ENTRY_REFRESH_PARAM`) : la connexion HGDC part même si la session du
43
+ * produit est ouverte. La nouvelle session relit le claim `organizations` (crochet
44
+ * `session.create.before`) ; retour vers `next?synced=1` (`HGDC_SYNCED_PARAM`), pour que la
45
+ * page ne relance pas l'entrée. Un `next` qui porte déjà `synced=1` ne relance rien : pas de
46
+ * boucle. L'ancienne session n'est pas révoquée : si Account ne répond pas, la personne garde
47
+ * son accès, et la session expire seule (`SESSION_TTL_SECONDS`).
42
48
  *
43
49
  * `next` est filtré par `safeNextPath` (chemin du produit seulement, pas de redirection ouverte),
44
50
  * 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_ISSUER, HGDC_PROVIDER_ID, HGDC_SCOPES, parseHgdcProfile, safeNextPath, } from "./index.js";
1
+ import { HGDC_ENTRY_NEXT_PARAM, HGDC_ENTRY_REFRESH_PARAM, HGDC_ISSUER, HGDC_PROVIDER_ID, 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"]);
@@ -115,6 +115,12 @@ function authorizationUrl(body) {
115
115
  * - Sinon : la connexion HGDC part aussitôt (même appel que `HgdcSignInButton`, fait par le
116
116
  * serveur), retour vers `next`. Si la session d'Account est ouverte et le produit déjà
117
117
  * autorisé, Account renvoie sans formulaire : trois redirections, aucun clic.
118
+ * - `refresh=1` (`HGDC_ENTRY_REFRESH_PARAM`) : la connexion HGDC part même si la session du
119
+ * produit est ouverte. La nouvelle session relit le claim `organizations` (crochet
120
+ * `session.create.before`) ; retour vers `next?synced=1` (`HGDC_SYNCED_PARAM`), pour que la
121
+ * page ne relance pas l'entrée. Un `next` qui porte déjà `synced=1` ne relance rien : pas de
122
+ * boucle. L'ancienne session n'est pas révoquée : si Account ne répond pas, la personne garde
123
+ * son accès, et la session expire seule (`SESSION_TTL_SECONDS`).
118
124
  *
119
125
  * `next` est filtré par `safeNextPath` (chemin du produit seulement, pas de redirection ouverte),
120
126
  * puis better-auth le revérifie contre ses `trustedOrigins`.
@@ -135,9 +141,12 @@ export function createHgdcEntry(options) {
135
141
  return async (request) => {
136
142
  const url = new URL(request.url);
137
143
  const next = safeNextPath(url.searchParams.get(HGDC_ENTRY_NEXT_PARAM));
144
+ // 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;
138
147
  const cookies = [];
139
148
  try {
140
- if (request.headers.has("cookie")) {
149
+ if (!refresh && request.headers.has("cookie")) {
141
150
  const session = await options.proxy(new Request(`${url.origin}${basePath}/get-session`, { headers: internalHeaders(request, url.origin) }));
142
151
  cookies.push(...setCookies(session));
143
152
  if (session.ok && hasSessionBody(await readJson(session)))
@@ -148,7 +157,7 @@ export function createHgdcEntry(options) {
148
157
  const signIn = await options.proxy(new Request(`${url.origin}${basePath}/sign-in/oauth2`, {
149
158
  method: "POST",
150
159
  headers,
151
- body: JSON.stringify({ providerId: HGDC_PROVIDER_ID, callbackURL: next, errorCallbackURL }),
160
+ body: JSON.stringify({ providerId: HGDC_PROVIDER_ID, callbackURL, errorCallbackURL }),
152
161
  }));
153
162
  const target = signIn.ok ? authorizationUrl(await readJson(signIn)) : null;
154
163
  if (!target)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/auth",
3
- "version": "0.6.2",
3
+ "version": "0.7.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",