@hgdc/auth 0.6.2 → 0.7.1
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 +25 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +61 -2
- package/dist/react.d.ts +15 -0
- package/dist/react.js +27 -2
- package/dist/server.d.ts +6 -0
- package/dist/server.js +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,7 +104,31 @@ 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
|
-
|
|
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
|
+
```tsx
|
|
115
|
+
// Côté client, une fois les organisations chargées
|
|
116
|
+
import { useOrganizationRefresh } from "@hgdc/auth/react"
|
|
117
|
+
|
|
118
|
+
const refreshing = useOrganizationRefresh(viewer !== undefined && !space)
|
|
119
|
+
if (refreshing) return <FamilyLoader page />
|
|
120
|
+
if (!space) return <NotFound />
|
|
121
|
+
|
|
122
|
+
// Dans un loader ou un beforeLoad (rendu serveur compris)
|
|
123
|
+
import { hasSyncedMarker, hgdcRefreshPath } from "@hgdc/auth"
|
|
124
|
+
if (!organization && !hasSyncedMarker(location.searchStr))
|
|
125
|
+
throw redirect({ href: hgdcRefreshPath(location.href), reloadDocument: true })
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- 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`).
|
|
129
|
+
- Lien vers un produit avec relecture : `hgdcEntryUrl(origin, "/o/acme", { refresh: true })`.
|
|
130
|
+
|
|
131
|
+
Décisions : ADR 0010, ADR 0013.
|
|
108
132
|
|
|
109
133
|
## Garder une fonction Convex
|
|
110
134
|
|
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
|
|
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 (
|
|
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/react.d.ts
CHANGED
|
@@ -52,3 +52,18 @@ export declare function HgdcMark({ className }: {
|
|
|
52
52
|
* `className`, l'état d'attente est exposé par `aria-busy` et `data-pending`.
|
|
53
53
|
*/
|
|
54
54
|
export declare function HgdcSignInButton({ authClient, callbackURL, errorCallbackURL, onError, label, disabled, ...rest }: HgdcSignInButtonProps): ReactElement;
|
|
55
|
+
/**
|
|
56
|
+
* Page d'une organisation inconnue du produit, ou dont la personne n'est pas membre dans sa copie :
|
|
57
|
+
* une invitation acceptée dans Account n'y arrive qu'à la session suivante (ADR 0013). Quand
|
|
58
|
+
* `unknown` est vrai, part une fois vers `/auth/hgdc?refresh=1&next=<page>` et rend `true` (afficher
|
|
59
|
+
* un chargement). Au retour (`?synced=1`), rend `false` : afficher « introuvable ».
|
|
60
|
+
*
|
|
61
|
+
* Côté client seulement : à appeler une fois la liste des organisations chargée.
|
|
62
|
+
*
|
|
63
|
+
* ```tsx
|
|
64
|
+
* const refreshing = useOrganizationRefresh(viewer !== undefined && !space)
|
|
65
|
+
* if (refreshing) return <FamilyLoader page />
|
|
66
|
+
* if (!space) return <NotFound />
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function useOrganizationRefresh(unknown: boolean): boolean;
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { HGDC_PROVIDER_ID, hasSyncedMarker, hgdcRefreshPath } from "./index.js";
|
|
4
4
|
/**
|
|
5
5
|
* Part vers HGDC Account. Renvoie `{ ok: true }` quand la redirection est lancée,
|
|
6
6
|
* sinon l'erreur du client ou du réseau, sans lever.
|
|
@@ -50,3 +50,28 @@ export function HgdcSignInButton({ authClient, callbackURL = "/", errorCallbackU
|
|
|
50
50
|
}
|
|
51
51
|
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 Account…" : label })] }));
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Page d'une organisation inconnue du produit, ou dont la personne n'est pas membre dans sa copie :
|
|
55
|
+
* une invitation acceptée dans Account n'y arrive qu'à la session suivante (ADR 0013). Quand
|
|
56
|
+
* `unknown` est vrai, part une fois vers `/auth/hgdc?refresh=1&next=<page>` et rend `true` (afficher
|
|
57
|
+
* un chargement). Au retour (`?synced=1`), rend `false` : afficher « introuvable ».
|
|
58
|
+
*
|
|
59
|
+
* Côté client seulement : à appeler une fois la liste des organisations chargée.
|
|
60
|
+
*
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const refreshing = useOrganizationRefresh(viewer !== undefined && !space)
|
|
63
|
+
* if (refreshing) return <FamilyLoader page />
|
|
64
|
+
* if (!space) return <NotFound />
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function useOrganizationRefresh(unknown) {
|
|
68
|
+
const synced = typeof window === "undefined" || hasSyncedMarker(window.location.search);
|
|
69
|
+
const refreshing = unknown && !synced;
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!refreshing)
|
|
72
|
+
return;
|
|
73
|
+
const { pathname, search, hash } = window.location;
|
|
74
|
+
window.location.replace(hgdcRefreshPath(`${pathname}${search}${hash}`));
|
|
75
|
+
}, [refreshing]);
|
|
76
|
+
return refreshing;
|
|
77
|
+
}
|
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
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|