@hgdc/auth 0.11.0 → 0.13.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 +30 -4
- package/dist/convex.d.ts +59 -5
- package/dist/convex.js +73 -3
- package/dist/index.d.ts +29 -2
- package/dist/index.js +29 -2
- package/dist/server.d.ts +6 -0
- package/dist/server.js +53 -7
- package/package.json +1 -1
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=
|
|
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 coque (`AppShell`) 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
|
|
|
@@ -157,10 +158,10 @@ Une absence de session n'est jamais gardée. Déconnexion : recharger la page, o
|
|
|
157
158
|
## Garder une fonction Convex
|
|
158
159
|
|
|
159
160
|
```ts
|
|
160
|
-
import { requireOrgMember } from "@hgdc/auth/convex"
|
|
161
|
+
import { convexIssuers, requireOrgMember } from "@hgdc/auth/convex"
|
|
161
162
|
|
|
162
163
|
const { userId, role } = await requireOrgMember(ctx, organizationId, {
|
|
163
|
-
issuer: process.env
|
|
164
|
+
issuer: convexIssuers(process.env), // émetteurs des JWT du plugin convex
|
|
164
165
|
minRole: "admin",
|
|
165
166
|
load: async (ctx, { organizationId, userId }) => /* appartenance lue dans la base du produit */ null,
|
|
166
167
|
})
|
|
@@ -187,6 +188,31 @@ if (authErrorReason(error) === "insufficient_role") showUpgradeHint()
|
|
|
187
188
|
|
|
188
189
|
Les organisations arrivent par la portée `organizations` : claim `organizations: [{ id, slug, name, role }]` de l'id_token et de userinfo.
|
|
189
190
|
|
|
191
|
+
### Changer le domaine du déploiement Convex
|
|
192
|
+
|
|
193
|
+
Les jetons du plugin `convex` portent l'émetteur `CONVEX_SITE_URL`. Quand l'adresse du déploiement change (domaine personnalisé posé par `update_canonical_url`, migration vers un Convex auto-hébergé), les jetons déjà émis gardent l'ancienne pendant leur durée de vie (15 minutes) : Convex les refuse sur une connexion neuve (`NoAuthProvider`) et la garde répond `wrong_issuer` sur un WebSocket ouvert. La variable `CONVEX_PREVIOUS_SITE_URL` ouvre une fenêtre où les deux émetteurs sont acceptés :
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// convex/auth.config.ts
|
|
197
|
+
import { convexAuthProviders } from "@hgdc/auth/convex"
|
|
198
|
+
|
|
199
|
+
export default {
|
|
200
|
+
providers: convexAuthProviders(getAuthConfigProvider({ jwks: process.env.JWKS }), process.env),
|
|
201
|
+
} satisfies AuthConfig
|
|
202
|
+
|
|
203
|
+
// convex/auth.ts, dans les plugins : le plugin refuse deux fournisseurs « convex »
|
|
204
|
+
convex({ authConfig: currentAuthConfig(authConfig), jwks: process.env.JWKS })
|
|
205
|
+
|
|
206
|
+
// gardes
|
|
207
|
+
await requireIdentity(ctx, { issuer: convexIssuers(process.env) })
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
1. Poser `CONVEX_PREVIOUS_SITE_URL` à l'adresse actuelle (`npx convex env set`), puis `convex deploy`.
|
|
211
|
+
2. Changer l'adresse. Les nouveaux jetons portent le nouvel émetteur, les anciens restent acceptés.
|
|
212
|
+
3. Attendre 30 minutes (deux fois la durée d'un jeton), retirer la variable, redéployer.
|
|
213
|
+
|
|
214
|
+
Hors bascule, la variable est absente et rien ne change : `convexAuthProviders` rend le seul fournisseur, `convexIssuers` la seule `CONVEX_SITE_URL`. La liste ne sert qu'à donner deux adresses au même émetteur (même clé, mêmes utilisateurs), jamais à accepter un autre fournisseur.
|
|
215
|
+
|
|
190
216
|
## Synchroniser les organisations
|
|
191
217
|
|
|
192
218
|
Les organisations vivent dans Account ; le produit en garde une copie (espace par organisation, appartenances) que lit le chargeur de `requireOrgMember`. Le modèle recommandé, celui de Host :
|
package/dist/convex.d.ts
CHANGED
|
@@ -50,15 +50,69 @@ export declare function isAuthError(error: unknown): error is ConvexError<AuthEr
|
|
|
50
50
|
export declare function authErrorReason(error: unknown): AuthErrorReason | null;
|
|
51
51
|
/**
|
|
52
52
|
* Émetteur des jetons dont le `subject` désigne un utilisateur du produit. Avec le plugin
|
|
53
|
-
* `convex` de better-auth : `process.env.CONVEX_SITE_URL
|
|
54
|
-
* son émetteur : sans ce contrôle, un autre fournisseur
|
|
55
|
-
* le `subject` d'un membre et prendre ses droits.
|
|
56
|
-
*
|
|
53
|
+
* `convex` de better-auth : `process.env.CONVEX_SITE_URL`, ou `convexIssuers(process.env)`.
|
|
54
|
+
* Un `subject` n'est unique que chez son émetteur : sans ce contrôle, un autre fournisseur
|
|
55
|
+
* accepté par Convex pourrait présenter le `subject` d'un membre et prendre ses droits.
|
|
56
|
+
*
|
|
57
|
+
* Une liste ne sert qu'à donner plusieurs adresses au même émetteur, le temps d'un changement
|
|
58
|
+
* de domaine du déploiement : l'appartenance est cherchée par `subject`, qui ne distingue pas
|
|
59
|
+
* deux fournisseurs. Jamais deux bases d'utilisateurs différentes.
|
|
57
60
|
*/
|
|
58
61
|
export type IssuerOption = {
|
|
62
|
+
issuer: string | readonly string[];
|
|
63
|
+
};
|
|
64
|
+
/** Variables d'environnement lues par `convexIssuers` et `convexAuthProviders`. */
|
|
65
|
+
export type ConvexIssuerEnv = Readonly<Record<string, string | undefined>>;
|
|
66
|
+
/**
|
|
67
|
+
* Émetteurs des jetons du plugin `convex` de better-auth sur ce déploiement, à passer tels quels
|
|
68
|
+
* à `requireIdentity` et `requireOrgMember` : `CONVEX_SITE_URL`, plus `CONVEX_PREVIOUS_SITE_URL`
|
|
69
|
+
* quand elle est posée.
|
|
70
|
+
*
|
|
71
|
+
* `CONVEX_PREVIOUS_SITE_URL` ne vit que pendant la bascule d'un domaine : l'ancienne adresse
|
|
72
|
+
* `actions` du déploiement, pour que les jetons émis avant la bascule restent valables jusqu'à
|
|
73
|
+
* leur expiration (15 minutes). Elle se retire ensuite.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* await requireIdentity(ctx, { issuer: convexIssuers(process.env) })
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export declare function convexIssuers(env: ConvexIssuerEnv): string[];
|
|
80
|
+
/**
|
|
81
|
+
* Fournisseurs de `convex/auth.config.ts` : `provider` (celui de `getAuthConfigProvider`), plus
|
|
82
|
+
* sa copie pour `CONVEX_PREVIOUS_SITE_URL` quand elle est posée, avec la même clé. Sans elle,
|
|
83
|
+
* Convex refuse dès la bascule (`NoAuthProvider`) un jeton émis avant, sur une connexion neuve.
|
|
84
|
+
*
|
|
85
|
+
* La copie est gardée même si les deux adresses sont égales : elles le sont entre la pose de la
|
|
86
|
+
* variable et le changement de domaine, et seule `CONVEX_SITE_URL` suit alors le domaine.
|
|
87
|
+
* Le plugin `convex` de better-auth refuse deux fournisseurs `applicationID: "convex"` : il
|
|
88
|
+
* reçoit `currentAuthConfig(authConfig)`.
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* export default {
|
|
92
|
+
* providers: convexAuthProviders(getAuthConfigProvider({ jwks: process.env.JWKS }), process.env),
|
|
93
|
+
* } satisfies AuthConfig
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export declare function convexAuthProviders<P extends {
|
|
59
97
|
issuer: string;
|
|
98
|
+
}>(provider: P, env: ConvexIssuerEnv): P[];
|
|
99
|
+
/**
|
|
100
|
+
* Configuration à donner au plugin `convex` de better-auth : le seul fournisseur courant, le
|
|
101
|
+
* premier de `convexAuthProviders`.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* convex({ authConfig: currentAuthConfig(authConfig), jwks: process.env.JWKS })
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export declare function currentAuthConfig<P>(config: {
|
|
108
|
+
providers: readonly P[];
|
|
109
|
+
}): {
|
|
110
|
+
providers: P[];
|
|
60
111
|
};
|
|
61
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Identité de la personne connectée, émise par `issuer` (ou l'un d'eux), ou erreur
|
|
114
|
+
* `UNAUTHENTICATED`.
|
|
115
|
+
*/
|
|
62
116
|
export declare function requireIdentity(ctx: {
|
|
63
117
|
auth: Auth;
|
|
64
118
|
}, options: IssuerOption): Promise<UserIdentity>;
|
package/dist/convex.js
CHANGED
|
@@ -45,14 +45,84 @@ export function authErrorReason(error) {
|
|
|
45
45
|
function normalizeIssuer(value) {
|
|
46
46
|
return value.replace(/\/+$/, "");
|
|
47
47
|
}
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Émetteurs des jetons du plugin `convex` de better-auth sur ce déploiement, à passer tels quels
|
|
50
|
+
* à `requireIdentity` et `requireOrgMember` : `CONVEX_SITE_URL`, plus `CONVEX_PREVIOUS_SITE_URL`
|
|
51
|
+
* quand elle est posée.
|
|
52
|
+
*
|
|
53
|
+
* `CONVEX_PREVIOUS_SITE_URL` ne vit que pendant la bascule d'un domaine : l'ancienne adresse
|
|
54
|
+
* `actions` du déploiement, pour que les jetons émis avant la bascule restent valables jusqu'à
|
|
55
|
+
* leur expiration (15 minutes). Elle se retire ensuite.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* await requireIdentity(ctx, { issuer: convexIssuers(process.env) })
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function convexIssuers(env) {
|
|
62
|
+
const issuers = [];
|
|
63
|
+
for (const value of [env.CONVEX_SITE_URL, env.CONVEX_PREVIOUS_SITE_URL]) {
|
|
64
|
+
const issuer = value ? normalizeIssuer(value.trim()) : "";
|
|
65
|
+
if (issuer && !issuers.includes(issuer))
|
|
66
|
+
issuers.push(issuer);
|
|
67
|
+
}
|
|
68
|
+
return issuers;
|
|
69
|
+
}
|
|
70
|
+
/** Lit une variable facultative dans `auth.config.ts`, où Convex lève sur une variable absente. */
|
|
71
|
+
function optionalEnv(env, name) {
|
|
72
|
+
try {
|
|
73
|
+
return env[name]?.trim() || undefined;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Fournisseurs de `convex/auth.config.ts` : `provider` (celui de `getAuthConfigProvider`), plus
|
|
81
|
+
* sa copie pour `CONVEX_PREVIOUS_SITE_URL` quand elle est posée, avec la même clé. Sans elle,
|
|
82
|
+
* Convex refuse dès la bascule (`NoAuthProvider`) un jeton émis avant, sur une connexion neuve.
|
|
83
|
+
*
|
|
84
|
+
* La copie est gardée même si les deux adresses sont égales : elles le sont entre la pose de la
|
|
85
|
+
* variable et le changement de domaine, et seule `CONVEX_SITE_URL` suit alors le domaine.
|
|
86
|
+
* Le plugin `convex` de better-auth refuse deux fournisseurs `applicationID: "convex"` : il
|
|
87
|
+
* reçoit `currentAuthConfig(authConfig)`.
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* export default {
|
|
91
|
+
* providers: convexAuthProviders(getAuthConfigProvider({ jwks: process.env.JWKS }), process.env),
|
|
92
|
+
* } satisfies AuthConfig
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function convexAuthProviders(provider, env) {
|
|
96
|
+
// Convex refuse un push dont auth.config.ts lit une variable absente, et n'y énumère pas
|
|
97
|
+
// `process.env` (`in`, `Object.keys` ne voient rien) : seule une lecture directe, rattrapée, convient.
|
|
98
|
+
const previous = optionalEnv(env, "CONVEX_PREVIOUS_SITE_URL");
|
|
99
|
+
return previous ? [provider, { ...provider, issuer: normalizeIssuer(previous) }] : [provider];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Configuration à donner au plugin `convex` de better-auth : le seul fournisseur courant, le
|
|
103
|
+
* premier de `convexAuthProviders`.
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* convex({ authConfig: currentAuthConfig(authConfig), jwks: process.env.JWKS })
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export function currentAuthConfig(config) {
|
|
110
|
+
return { providers: config.providers.slice(0, 1) };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Identité de la personne connectée, émise par `issuer` (ou l'un d'eux), ou erreur
|
|
114
|
+
* `UNAUTHENTICATED`.
|
|
115
|
+
*/
|
|
49
116
|
export async function requireIdentity(ctx, options) {
|
|
50
117
|
const identity = await ctx.auth.getUserIdentity();
|
|
51
118
|
if (!identity)
|
|
52
119
|
throw authError("no_session");
|
|
53
|
-
|
|
120
|
+
const expected = (typeof options.issuer === "string" ? [options.issuer] : options.issuer)
|
|
121
|
+
.map(normalizeIssuer)
|
|
122
|
+
.filter((issuer) => issuer !== "");
|
|
123
|
+
if (expected.length === 0)
|
|
54
124
|
throw new Error("requireIdentity : émetteur attendu manquant (CONVEX_SITE_URL ?).");
|
|
55
|
-
if (normalizeIssuer(identity.issuer)
|
|
125
|
+
if (!expected.includes(normalizeIssuer(identity.issuer)))
|
|
56
126
|
throw authError("wrong_issuer");
|
|
57
127
|
return identity;
|
|
58
128
|
}
|
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/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 =
|
|
146
|
-
const
|
|
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(
|
|
187
|
+
return redirectTo(errorURL, cookies);
|
|
165
188
|
return redirectTo(target, [...cookies, ...setCookies(signIn)]);
|
|
166
189
|
}
|
|
167
190
|
catch {
|
|
168
|
-
return redirectTo(
|
|
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.
|
|
3
|
+
"version": "0.13.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",
|