mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #122 from IliesAllali/feat/connexion-oidc
Auto-hébergement : connexion par un fournisseur d'identité OIDC
This commit is contained in:
28 files changed
+1327
-150
No files matched your search
@@ -41,6 +41,19 @@ CORS_ORIGIN=http://localhost
|
||||
# créez-le juste après le premier démarrage, avant de donner l'adresse.
|
||||
# ALLOW_REGISTRATION=true
|
||||
|
||||
# ── Connexion par un fournisseur d'identité (OIDC : Authentik, Authelia, Keycloak…) ──
|
||||
# Éteinte sans OIDC_ISSUER. Chez le fournisseur, adresse de retour = adresse publique + /auth/oidc/callback.
|
||||
# Détails dans le README, section « Connexion par votre fournisseur d'identité ».
|
||||
# OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/
|
||||
# OIDC_CLIENT_ID=parenthese
|
||||
# OIDC_CLIENT_SECRET=
|
||||
# OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback
|
||||
# OIDC_PROVIDER_NAME=Authentik
|
||||
# Email accepté seulement s'il est vérifié chez le fournisseur (email_verified).
|
||||
# OIDC_REQUIRE_VERIFIED_EMAIL=true
|
||||
# À false, plus de mot de passe Parenthèse : connexion par le fournisseur seulement.
|
||||
# PASSWORD_LOGIN=true
|
||||
|
||||
# ── Immich (reconnaissance des visages, désactivé par défaut) ────────────────
|
||||
# Instances Immich autorisées sur ce serveur, séparées par des virgules. Chaque entrée
|
||||
# peut recevoir un libellé, « Libellé=https://adresse » ; sans libellé, c'est le nom
|
||||
|
||||
@@ -59,6 +59,22 @@ Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HT
|
||||
|
||||
Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y créer ses arbres, mettez `ALLOW_REGISTRATION=false` dans `.env`, puis lancez `docker compose up -d`. Les comptes existants se connectent et créent leurs arbres comme avant, la famille ouvre toujours les arbres partagés avec le lien et le mot de passe, et l'application ne propose plus de créer de compte. Une instance sans aucun compte accepte quand même le premier, la variable peut donc être posée avant le tout premier démarrage : créez votre compte tout de suite, avant de donner l'adresse.
|
||||
|
||||
#### Connexion par votre fournisseur d'identité (OIDC)
|
||||
|
||||
Parenthèse peut confier la connexion à un fournisseur OpenID Connect que vous faites déjà tourner (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…) : la famille garde un seul compte et sa double authentification. Créez chez lui une application OIDC (client confidentiel, flux « authorization code ») dont l'adresse de retour est l'adresse publique de Parenthèse suivie de `/auth/oidc/callback`, puis renseignez dans `.env` :
|
||||
|
||||
```bash
|
||||
OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/ # exactement comme le fournisseur l'annonce
|
||||
OIDC_CLIENT_ID=parenthese
|
||||
OIDC_CLIENT_SECRET=... # vide pour un client public (PKCE seul)
|
||||
OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback
|
||||
OIDC_PROVIDER_NAME=Authentik # nom affiché sur le bouton
|
||||
```
|
||||
|
||||
L'écran de connexion affiche alors « Se connecter avec Authentik ». À la première connexion, Parenthèse relie le compte du fournisseur au compte Parenthèse qui a le même email, ou en crée un si `ALLOW_REGISTRATION` le permet. Inscription fermée, le fournisseur ne connecte que les personnes qui ont déjà un compte (le premier compte d'une instance vide reste accepté). Un email n'est pris en compte que si le fournisseur le dit vérifié (`email_verified`) ; `OIDC_REQUIRE_VERIFIED_EMAIL=false` lève ce contrôle, pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse. Une fois relié, un compte suit l'identifiant de l'utilisateur chez le fournisseur, pas son email.
|
||||
|
||||
`PASSWORD_LOGIN=false` retire les mots de passe Parenthèse : la connexion passe par le fournisseur seulement (l'API refuse de démarrer sans `OIDC_ISSUER`). Un compte créé par le fournisseur n'a pas de mot de passe ; pour le supprimer, on retape son email. `OIDC_SCOPES` (par défaut `openid email profile`) et `OIDC_APP_URL` (où l'app s'ouvre après la connexion, par défaut l'origine de `OIDC_REDIRECT_URI`) se changent rarement.
|
||||
|
||||
Une instance auto-hébergée n'envoie aucune mesure d'audience : le suivi d'usage de parenthese.io ne s'active que si une clé PostHog est fournie au build, ce que cette configuration ne fait pas.
|
||||
|
||||
Les liens d'aide et de vie privée de l'application mènent à parenthese.io. Pour pointer vers vos propres pages, renseignez `VITE_HELP_URL` et `VITE_PRIVACY_URL` quand vous construisez vous-même le front (voir `frontend/.env.example`).
|
||||
|
||||
@@ -65,6 +65,22 @@ To open the instance to your family over the Internet, put an HTTPS reverse prox
|
||||
|
||||
So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address.
|
||||
|
||||
#### Sign in with your identity provider (OIDC)
|
||||
|
||||
Parenthèse can hand sign-in to an OpenID Connect provider you already run (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…), so your family reuses one account and its two-factor settings. Create an OIDC application (confidential client, authorization code flow) whose redirect URI is the public address of Parenthèse followed by `/auth/oidc/callback`, then set in `.env`:
|
||||
|
||||
```bash
|
||||
OIDC_ISSUER=https://auth.your-domain.com/application/o/parenthese/ # exactly as the provider announces it
|
||||
OIDC_CLIENT_ID=parenthese
|
||||
OIDC_CLIENT_SECRET=... # empty for a public client (PKCE only)
|
||||
OIDC_REDIRECT_URI=https://tree.your-domain.com/auth/oidc/callback
|
||||
OIDC_PROVIDER_NAME=Authentik # shown on the button
|
||||
```
|
||||
|
||||
The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email.
|
||||
|
||||
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
|
||||
|
||||
A self-hosted instance sends no analytics. Usage tracking on parenthese.io only turns on when a PostHog key is provided at build time, which this setup does not do.
|
||||
|
||||
The help and privacy links in the app point to parenthese.io. To use your own pages, set `VITE_HELP_URL` and `VITE_PRIVACY_URL` when you build the front end yourself (see `frontend/.env.example`).
|
||||
|
||||
@@ -14,3 +14,12 @@ IMMICH_ALLOWED_API_URLS=""
|
||||
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
|
||||
# compte accepte quand même le premier, celui du propriétaire.
|
||||
ALLOW_REGISTRATION=true
|
||||
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
|
||||
# les mots de passe Parenthèse.
|
||||
OIDC_ISSUER=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_REDIRECT_URI=""
|
||||
OIDC_PROVIDER_NAME="SSO"
|
||||
OIDC_REQUIRE_VERIFIED_EMAIL=true
|
||||
PASSWORD_LOGIN=true
|
||||
@@ -33,3 +33,12 @@ IMMICH_ALLOWED_API_URLS=""
|
||||
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
|
||||
# compte accepte quand même le premier, celui du propriétaire.
|
||||
ALLOW_REGISTRATION=true
|
||||
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
|
||||
# les mots de passe Parenthèse.
|
||||
OIDC_ISSUER=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_REDIRECT_URI=""
|
||||
OIDC_PROVIDER_NAME="SSO"
|
||||
OIDC_REQUIRE_VERIFIED_EMAIL=true
|
||||
PASSWORD_LOGIN=true
|
||||
+71
-2
@@ -553,7 +553,70 @@ paths:
|
||||
registration:
|
||||
type: boolean
|
||||
description: false when ALLOW_REGISTRATION=false and the instance already has an account
|
||||
required: [registration]
|
||||
passwordLogin:
|
||||
type: boolean
|
||||
description: false when PASSWORD_LOGIN=false (sign-in through the OIDC provider only)
|
||||
oidc:
|
||||
type: object
|
||||
nullable: true
|
||||
description: OIDC provider shown on the sign-in screen, null when OIDC_ISSUER is not set
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required: [registration, passwordLogin, oidc]
|
||||
|
||||
/auth/oidc/start:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Start an OIDC sign-in (browser navigation)
|
||||
description: Sets a signed, httpOnly cookie holding state, nonce and PKCE verifier, then redirects to the provider. Only exists when OIDC_ISSUER is set.
|
||||
parameters:
|
||||
- name: returnTo
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Either / or /arbre/<slug>, anything else falls back to /
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect to the provider, or back to the app with "#oidc_error=oidc_unavailable"
|
||||
|
||||
/auth/oidc/callback:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Provider redirect target (browser navigation)
|
||||
description: Checks the cookie, then lets openid-client exchange the code and verify the ID token (signature, issuer, audience, expiry, nonce, state, PKCE). Finds the account by provider subject, links a verified email, or creates an account when registration allows it.
|
||||
responses:
|
||||
'302':
|
||||
description: Back to the app with "#oidc=<one-time handoff>" or "#oidc_error=oidc_failed|oidc_email_unverified|oidc_email_missing|registration_closed"
|
||||
|
||||
/auth/oidc/exchange:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Trade the one-time handoff for a session
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
required: [code]
|
||||
responses:
|
||||
'200':
|
||||
description: Session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthResponse'
|
||||
'401':
|
||||
description: Handoff invalid, expired or already used (invalid_handoff)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/register:
|
||||
post:
|
||||
@@ -579,7 +642,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: Registration closed on this instance (registration_closed)
|
||||
description: Registration closed on this instance (registration_closed), or PASSWORD_LOGIN=false (password_login_disabled)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -620,6 +683,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: PASSWORD_LOGIN=false (password_login_disabled)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Invalid credentials
|
||||
content:
|
||||
|
||||
Generated
+66
-30
@@ -8,6 +8,7 @@
|
||||
"name": "backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
@@ -15,6 +16,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^18.0.7",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -499,6 +501,39 @@
|
||||
"fast-uri": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.3.tgz",
|
||||
"integrity": "sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^2.0.0",
|
||||
"fastify-plugin": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie/node_modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.1.tgz",
|
||||
@@ -1409,9 +1444,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1430,9 +1462,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1451,9 +1480,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1472,9 +1498,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1493,9 +1516,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1514,9 +1534,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2670,6 +2687,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.13",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
|
||||
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||
@@ -2881,9 +2907,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2906,9 +2929,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2931,9 +2951,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2956,9 +2973,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3094,6 +3108,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
@@ -3130,6 +3153,19 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "6.8.8",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz",
|
||||
"integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^6.2.12",
|
||||
"oauth4webapi": "^3.8.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"prisma:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^18.0.7",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Connexion OIDC (#115) : un compte peut naître sans mot de passe, et ses identités chez un fournisseur sont gardées
|
||||
-- à part (émetteur + sub). Migration additive : les comptes existants gardent leur mot de passe.
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_identities" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"issuer" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_identities_userId_idx" ON "user_identities"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_identities_issuer_subject_key" ON "user_identities"("issuer", "subject");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -82,7 +82,8 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
firstName String?
|
||||
passwordHash String
|
||||
// Vide pour un compte né d'une connexion OIDC : il n'a pas de mot de passe Parenthèse
|
||||
passwordHash String?
|
||||
lastOpenedTreeId String?
|
||||
lastOpenedAccessMode String?
|
||||
lastOpenedRole String?
|
||||
@@ -94,12 +95,28 @@ model User {
|
||||
submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy")
|
||||
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
|
||||
treeVisits TreeVisit[]
|
||||
identities UserIdentity[]
|
||||
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([lastOpenedAt])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// Compte d'un fournisseur OIDC relié à un compte Parenthèse : l'émetteur et le « sub » de l'ID token, jamais l'email,
|
||||
// qui peut changer chez le fournisseur.
|
||||
model UserIdentity {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
issuer String
|
||||
subject String
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([issuer, subject])
|
||||
@@index([userId])
|
||||
@@map("user_identities")
|
||||
}
|
||||
|
||||
model Tree {
|
||||
id String @id @default(cuid())
|
||||
ownerUserId String
|
||||
|
||||
+12
-2
@@ -1,4 +1,6 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
import Fastify, { type FastifyError } from 'fastify'
|
||||
import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
@@ -12,6 +14,7 @@ import { contributionRoutes } from './routes/contributions.js'
|
||||
import { graphRoutes } from './routes/graph.js'
|
||||
import { immichRoutes } from './routes/immich.js'
|
||||
import { mediaRoutes } from './routes/media.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { publicRoutes } from './routes/public.js'
|
||||
import { annotationRoutes } from './routes/annotations.js'
|
||||
import { gedcomRoutes } from './routes/gedcom.js'
|
||||
@@ -19,9 +22,10 @@ import { treeRoutes } from './routes/trees.js'
|
||||
import { visitRoutes } from './routes/visits.js'
|
||||
import type { AnyJwtPayload } from './types/auth.js'
|
||||
|
||||
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête) : jamais dans les journaux
|
||||
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête), le retour OIDC un code
|
||||
// d'autorisation : jamais dans les journaux
|
||||
export function redactUrl(url: string | undefined): string | undefined {
|
||||
return url?.replace(/([?&]token=)[^&#]*/g, '$1[masqué]')
|
||||
return url?.replace(/([?&](?:token|code)=)[^&#]*/g, '$1[masqué]')
|
||||
}
|
||||
|
||||
export function createApp() {
|
||||
@@ -68,6 +72,11 @@ export function createApp() {
|
||||
secret: env.JWT_SECRET,
|
||||
})
|
||||
|
||||
// Cookie signé du parcours OIDC seulement (state, nonce, PKCE entre /auth/oidc/start et le retour) : les sessions
|
||||
// restent des jetons Bearer
|
||||
// Clé propre au cookie, dérivée de JWT_SECRET : une signature de cookie ne vaut jamais signature de jeton
|
||||
app.register(cookie, { secret: createHmac('sha256', env.JWT_SECRET).update('parenthese-oidc-cookie').digest('hex') })
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
request.actor = null
|
||||
|
||||
@@ -140,6 +149,7 @@ export function createApp() {
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
app.register(authRoutes, { prefix: '/auth' })
|
||||
app.register(oidcRoutes, { prefix: '/auth/oidc' })
|
||||
app.register(publicRoutes)
|
||||
app.register(treeRoutes)
|
||||
app.register(mediaRoutes)
|
||||
|
||||
@@ -68,10 +68,54 @@ const envSchema = z.object({
|
||||
// Internet ne laisse pas un inconnu qui trouve l'adresse créer ses arbres. Une instance sans aucun compte
|
||||
// accepte quand même le premier, celui du propriétaire (voir lib/registration.ts).
|
||||
ALLOW_REGISTRATION: z.string().default('true').transform(parseBooleanFlag('ALLOW_REGISTRATION')),
|
||||
// Connexion par un fournisseur d'identité OIDC (#115 ; Authentik, Authelia, Keycloak, Pocket ID…). Éteinte sans
|
||||
// OIDC_ISSUER. Le fournisseur renvoie vers OIDC_REDIRECT_URI, l'adresse publique de /auth/oidc/callback.
|
||||
OIDC_ISSUER: z.string().trim().default(''),
|
||||
OIDC_CLIENT_ID: z.string().trim().default(''),
|
||||
// Vide : client public, protégé par PKCE seul
|
||||
OIDC_CLIENT_SECRET: z.string().default(''),
|
||||
OIDC_REDIRECT_URI: z.string().trim().default(''),
|
||||
// Où l'app s'ouvre après la connexion. Par défaut l'origine de OIDC_REDIRECT_URI (Docker : front et API sur la
|
||||
// même adresse) ; à renseigner quand le front a sa propre adresse (développement).
|
||||
OIDC_APP_URL: z.string().trim().default(''),
|
||||
OIDC_SCOPES: z.string().default('openid email profile'),
|
||||
// Nom du bouton « Se connecter avec … »
|
||||
OIDC_PROVIDER_NAME: z.string().trim().max(40).default('SSO'),
|
||||
// Un compte n'est créé ou relié par son email que si le fournisseur le dit vérifié (email_verified). À false
|
||||
// seulement pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse.
|
||||
OIDC_REQUIRE_VERIFIED_EMAIL: z.string().default('true').transform(parseBooleanFlag('OIDC_REQUIRE_VERIFIED_EMAIL')),
|
||||
// À false, plus de mot de passe Parenthèse : connexion par le fournisseur OIDC seulement
|
||||
PASSWORD_LOGIN: z.string().default('true').transform(parseBooleanFlag('PASSWORD_LOGIN')),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
function assertHttpUrl(name: string, value: string) {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new Error(`${name} must be a full URL`)
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new Error(`${name} must be an http(s) URL`)
|
||||
}
|
||||
}
|
||||
|
||||
if (env.OIDC_ISSUER) {
|
||||
assertHttpUrl('OIDC_ISSUER', env.OIDC_ISSUER)
|
||||
if (!env.OIDC_CLIENT_ID) throw new Error('OIDC_CLIENT_ID is required with OIDC_ISSUER')
|
||||
if (!env.OIDC_REDIRECT_URI) throw new Error('OIDC_REDIRECT_URI is required with OIDC_ISSUER (public address of /auth/oidc/callback)')
|
||||
assertHttpUrl('OIDC_REDIRECT_URI', env.OIDC_REDIRECT_URI)
|
||||
if (env.OIDC_APP_URL) assertHttpUrl('OIDC_APP_URL', env.OIDC_APP_URL)
|
||||
if (!env.OIDC_SCOPES.split(/\s+/).includes('openid')) throw new Error('OIDC_SCOPES must include openid')
|
||||
}
|
||||
|
||||
// Sans mot de passe ni fournisseur, personne ne pourrait se connecter
|
||||
if (!env.PASSWORD_LOGIN && !env.OIDC_ISSUER) {
|
||||
throw new Error('PASSWORD_LOGIN=false needs OIDC_ISSUER: otherwise nobody can sign in')
|
||||
}
|
||||
|
||||
// Corps maximal des routes qui reçoivent un fichier en base64 ou un gros envoi (le reste de l'API : 1 Mo)
|
||||
export const UPLOAD_BODY_LIMIT = 12 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as client from 'openid-client'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
// Connexion par un fournisseur OIDC (#115). Tout le protocole passe par openid-client : découverte, PKCE (S256),
|
||||
// state et nonce, échange du code, puis vérification de l'ID token (signature par les clés publiées du
|
||||
// fournisseur, émetteur, audience = notre client, expiration, nonce). Aucun jeton n'est décodé à la main ici.
|
||||
|
||||
export type OidcPending = {
|
||||
state: string
|
||||
nonce: string
|
||||
codeVerifier: string
|
||||
returnTo: string
|
||||
}
|
||||
|
||||
export type OidcIdentity = {
|
||||
issuer: string
|
||||
subject: string
|
||||
email: string | null
|
||||
emailVerified: boolean
|
||||
firstName: string | null
|
||||
}
|
||||
|
||||
export function isOidcEnabled(): boolean {
|
||||
return Boolean(env.OIDC_ISSUER)
|
||||
}
|
||||
|
||||
let configuration: Promise<client.Configuration> | null = null
|
||||
|
||||
// Découverte faite au premier besoin puis gardée ; un échec (fournisseur injoignable) sera retenté à la connexion suivante
|
||||
export function getOidcConfiguration(): Promise<client.Configuration> {
|
||||
configuration ??= discover().catch((error: unknown) => {
|
||||
configuration = null
|
||||
throw error
|
||||
})
|
||||
return configuration
|
||||
}
|
||||
|
||||
function discover(): Promise<client.Configuration> {
|
||||
const issuer = new URL(env.OIDC_ISSUER)
|
||||
// Sans secret : client public, PKCE seul. Avec : client_secret_basic, la méthode par défaut d'OIDC.
|
||||
const authentication = env.OIDC_CLIENT_SECRET ? client.ClientSecretBasic(env.OIDC_CLIENT_SECRET) : client.None()
|
||||
// Un fournisseur en http (réseau local) doit être autorisé explicitement par openid-client
|
||||
const options = issuer.protocol === 'http:' ? { execute: [client.allowInsecureRequests] } : undefined
|
||||
return client.discovery(issuer, env.OIDC_CLIENT_ID, undefined, authentication, options)
|
||||
}
|
||||
|
||||
// Adresse de l'app où revenir : OIDC_APP_URL, sinon l'origine de l'adresse de retour (front et API sur la même adresse)
|
||||
export function appBaseUrl(): string {
|
||||
return (env.OIDC_APP_URL || new URL(env.OIDC_REDIRECT_URI).origin).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// Seules la racine et l'adresse d'un arbre partagé sont acceptées comme retour : pas de redirection ouverte
|
||||
export function safeReturnTo(value: unknown): string {
|
||||
return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64})?$/.test(value) ? value : '/'
|
||||
}
|
||||
|
||||
export async function startAuthorization(returnTo: string): Promise<{ url: URL; pending: OidcPending }> {
|
||||
const config = await getOidcConfiguration()
|
||||
const codeVerifier = client.randomPKCECodeVerifier()
|
||||
const pending: OidcPending = {
|
||||
state: client.randomState(),
|
||||
nonce: client.randomNonce(),
|
||||
codeVerifier,
|
||||
returnTo,
|
||||
}
|
||||
const url = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||||
scope: env.OIDC_SCOPES,
|
||||
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
state: pending.state,
|
||||
nonce: pending.nonce,
|
||||
})
|
||||
return { url, pending }
|
||||
}
|
||||
|
||||
function stringClaim(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
// Retour du fournisseur. L'adresse vérifiée est OIDC_REDIRECT_URI plus les paramètres reçus, jamais l'en-tête Host.
|
||||
export async function finishAuthorization(callbackQuery: string, pending: OidcPending): Promise<OidcIdentity> {
|
||||
const config = await getOidcConfiguration()
|
||||
const currentUrl = new URL(env.OIDC_REDIRECT_URI)
|
||||
currentUrl.search = callbackQuery
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
})
|
||||
|
||||
const claims = tokens.claims()
|
||||
if (!claims) {
|
||||
throw new Error('oidc_missing_id_token')
|
||||
}
|
||||
|
||||
let email = stringClaim(claims.email)
|
||||
let emailVerified = claims.email_verified === true
|
||||
let firstName = stringClaim(claims.given_name) ?? stringClaim(claims.name)
|
||||
|
||||
// Certains fournisseurs (Authelia par défaut) ne mettent l'email que dans userinfo. Le sujet attendu est
|
||||
// celui de l'ID token : une réponse userinfo pour un autre compte est refusée par openid-client.
|
||||
if (!email && tokens.access_token) {
|
||||
const info = await client.fetchUserInfo(config, tokens.access_token, claims.sub)
|
||||
email = stringClaim(info.email)
|
||||
emailVerified = info.email_verified === true
|
||||
firstName ??= stringClaim(info.given_name) ?? stringClaim(info.name)
|
||||
}
|
||||
|
||||
return {
|
||||
issuer: claims.iss,
|
||||
subject: claims.sub,
|
||||
email: email?.toLowerCase() ?? null,
|
||||
emailVerified,
|
||||
firstName: firstName?.slice(0, 60) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// Pour les tests : oublie la configuration découverte
|
||||
export function resetOidcConfiguration() {
|
||||
configuration = null
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
// Session d'un compte, la même quelle que soit la façon de se connecter (mot de passe ou fournisseur OIDC)
|
||||
export function signUserSession(app: FastifyInstance, user: { id: string; email: string }): string {
|
||||
return app.jwt.sign(
|
||||
{
|
||||
kind: 'user',
|
||||
sub: user.id,
|
||||
jti: randomUUID(),
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
{ expiresIn: env.JWT_EXPIRES_IN },
|
||||
)
|
||||
}
|
||||
+47
-33
@@ -4,7 +4,9 @@ import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { isOidcEnabled } from '../lib/oidc.js'
|
||||
import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
import { revokeUserToken } from '../lib/token-revocation.js'
|
||||
import { deletedAccountVisitorKey } from '../lib/tree-visits.js'
|
||||
import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
|
||||
@@ -32,9 +34,14 @@ function trimmedFirstName(value: string | null | undefined): string | null {
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
// Suppression d'un compte sans mot de passe : connexion chez le fournisseur de moins de 15 minutes
|
||||
const RECENT_SIGN_IN_MS = 15 * 60 * 1000
|
||||
|
||||
// Un compte avec mot de passe le redonne ; un compte né d'une connexion OIDC, sans mot de passe, retape son email
|
||||
const deleteAccountSchema = z.object({
|
||||
password: z.string().min(1).max(128),
|
||||
})
|
||||
password: z.string().min(1).max(128).optional(),
|
||||
confirmEmail: z.string().min(1).max(320).optional(),
|
||||
}).refine((value) => value.password || value.confirmEmail, { message: 'password or confirmEmail required' })
|
||||
|
||||
const updateLastTreeSchema = z.object({
|
||||
treeId: z.string().min(1),
|
||||
@@ -99,7 +106,11 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get(
|
||||
'/options',
|
||||
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
|
||||
async (_request, reply) => reply.send({ registration: await isRegistrationOpen(app.prisma) }),
|
||||
async (_request, reply) => reply.send({
|
||||
registration: await isRegistrationOpen(app.prisma),
|
||||
passwordLogin: env.PASSWORD_LOGIN,
|
||||
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
|
||||
}),
|
||||
)
|
||||
|
||||
app.post(
|
||||
@@ -118,6 +129,11 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
|
||||
}
|
||||
|
||||
// Connexion par le fournisseur seulement (PASSWORD_LOGIN=false) : pas de compte à mot de passe
|
||||
if (!env.PASSWORD_LOGIN) {
|
||||
return reply.code(403).send({ error: 'password_login_disabled' })
|
||||
}
|
||||
|
||||
// Inscription fermée : refusée avant toute lecture, la réponse ne dit pas si l'adresse a un compte
|
||||
if (!(await isRegistrationOpen(app.prisma))) {
|
||||
return reply.code(403).send({ error: 'registration_closed' })
|
||||
@@ -147,18 +163,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(403).send({ error: 'registration_closed' })
|
||||
}
|
||||
|
||||
const token = app.jwt.sign(
|
||||
{
|
||||
kind: 'user',
|
||||
sub: user.id,
|
||||
jti: randomUUID(),
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
{ expiresIn: env.JWT_EXPIRES_IN },
|
||||
)
|
||||
|
||||
return reply.code(201).send({ token, user })
|
||||
return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -173,6 +178,10 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!env.PASSWORD_LOGIN) {
|
||||
return reply.code(403).send({ error: 'password_login_disabled' })
|
||||
}
|
||||
|
||||
const parsed = credentialsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
|
||||
@@ -189,8 +198,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// Même durée qu'un mauvais mot de passe : la réponse ne dit pas si l'adresse a un compte
|
||||
// Adresse inconnue, ou compte né d'une connexion OIDC (sans mot de passe) : même durée qu'un mauvais mot de
|
||||
// passe, la réponse ne dit pas si l'adresse a un compte
|
||||
if (!user?.passwordHash) {
|
||||
await verifyPassword(parsed.data.password, await getDummyHash())
|
||||
return reply.code(401).send({ error: 'invalid_credentials' })
|
||||
}
|
||||
@@ -200,23 +210,13 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(401).send({ error: 'invalid_credentials' })
|
||||
}
|
||||
|
||||
const token = app.jwt.sign(
|
||||
{
|
||||
kind: 'user',
|
||||
sub: user.id,
|
||||
jti: randomUUID(),
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
{ expiresIn: env.JWT_EXPIRES_IN },
|
||||
)
|
||||
|
||||
return reply.send({
|
||||
token,
|
||||
token: signUserSession(app, user),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
hasPassword: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -230,13 +230,14 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: request.actor.userId },
|
||||
select: { id: true, email: true, firstName: true },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true },
|
||||
})
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
return reply.send({ user })
|
||||
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) } })
|
||||
})
|
||||
|
||||
app.get('/me/last-tree', async (request, reply) => {
|
||||
@@ -367,6 +368,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
})
|
||||
@@ -375,12 +377,24 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(parsed.data.password, user.passwordHash)
|
||||
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number; iat?: number }>()
|
||||
|
||||
if (!user.passwordHash) {
|
||||
// Compte né d'une connexion OIDC : pas de mot de passe à redonner. Il retape son email, et la session doit
|
||||
// venir d'une connexion récente chez le fournisseur (une session volée ou oubliée ouverte ne suffit pas).
|
||||
const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity
|
||||
if (sessionAgeMs > RECENT_SIGN_IN_MS) {
|
||||
return reply.code(403).send({ error: 'recent_sign_in_required' })
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = user.passwordHash
|
||||
? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash)
|
||||
: (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email
|
||||
if (!isValid) {
|
||||
return reply.code(403).send({ error: 'invalid_password' })
|
||||
}
|
||||
|
||||
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number }>()
|
||||
|
||||
// Arbres possédés (y compris ceux marqués supprimés) puis compte, dans une seule transaction :
|
||||
// la relation propriétaire est en Restrict, les arbres doivent partir avant l'utilisateur.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import {
|
||||
appBaseUrl,
|
||||
finishAuthorization,
|
||||
isOidcEnabled,
|
||||
safeReturnTo,
|
||||
startAuthorization,
|
||||
type OidcIdentity,
|
||||
type OidcPending,
|
||||
} from '../lib/oidc.js'
|
||||
import { createAccountIfAllowed } from '../lib/registration.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
|
||||
// Parcours : /auth/oidc/start pose un cookie signé (state, nonce, vérificateur PKCE) et part chez le fournisseur ;
|
||||
// /auth/oidc/callback vérifie tout, trouve ou crée le compte, et renvoie vers l'app avec un jeton de passage dans le
|
||||
// fragment (#oidc=…, jamais envoyé à un serveur ni écrit dans un journal) ; l'app l'échange une seule fois contre
|
||||
// une session sur /auth/oidc/exchange. Le cookie lie le retour au navigateur qui a commencé la connexion.
|
||||
|
||||
const PENDING_COOKIE = 'parenthese_oidc'
|
||||
const PENDING_MAX_AGE_S = 10 * 60
|
||||
const HANDOFF_TTL_S = 60
|
||||
|
||||
type HandoffPayload = { kind: 'oidc_handoff'; sub: string; jti: string; exp?: number }
|
||||
|
||||
// Jetons de passage déjà échangés, jusqu'à leur expiration (une seule instance de l'API, comme les sessions révoquées)
|
||||
const usedHandoffs = new Map<string, number>()
|
||||
function consumeHandoff(jti: string, expSeconds: number): boolean {
|
||||
const now = Date.now()
|
||||
for (const [key, expiresAt] of usedHandoffs) {
|
||||
if (expiresAt < now) usedHandoffs.delete(key)
|
||||
}
|
||||
if (usedHandoffs.has(jti)) return false
|
||||
usedHandoffs.set(jti, expSeconds * 1000)
|
||||
return true
|
||||
}
|
||||
|
||||
const pendingSchema = z.object({
|
||||
state: z.string().min(1),
|
||||
nonce: z.string().min(1),
|
||||
codeVerifier: z.string().min(43),
|
||||
returnTo: z.string(),
|
||||
})
|
||||
|
||||
const exchangeSchema = z.object({
|
||||
code: z.string().min(1).max(4096),
|
||||
})
|
||||
|
||||
type AccountResult = { userId: string } | { error: 'registration_closed' | 'oidc_email_missing' | 'oidc_email_unverified' }
|
||||
|
||||
// Compte relié à cette identité ; sinon le compte qui a cet email, si le fournisseur le garantit ; sinon un compte
|
||||
// neuf, seulement si l'instance en accepte (ALLOW_REGISTRATION, ou instance encore vide).
|
||||
async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Promise<AccountResult> {
|
||||
const linked = await app.prisma.userIdentity.findUnique({
|
||||
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
|
||||
select: { userId: true },
|
||||
})
|
||||
if (linked) return { userId: linked.userId }
|
||||
|
||||
if (!identity.email) return { error: 'oidc_email_missing' }
|
||||
if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
|
||||
const existing = await app.prisma.user.findUnique({ where: { email: identity.email }, select: { id: true } })
|
||||
if (existing) {
|
||||
// Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false :
|
||||
// sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte
|
||||
if (!identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
|
||||
await app.prisma.userIdentity.create({
|
||||
data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject },
|
||||
})
|
||||
return { userId: existing.id }
|
||||
}
|
||||
|
||||
const created = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
|
||||
data: {
|
||||
email: identity.email as string,
|
||||
firstName: identity.firstName,
|
||||
passwordHash: null,
|
||||
identities: { create: { issuer: identity.issuer, subject: identity.subject } },
|
||||
},
|
||||
select: { id: true },
|
||||
}))
|
||||
return created ? { userId: created.id } : { error: 'registration_closed' }
|
||||
}
|
||||
|
||||
function redirectToApp(reply: FastifyReply, returnTo: string, fragment: string) {
|
||||
reply.clearCookie(PENDING_COOKIE, { path: '/' })
|
||||
return reply.redirect(`${appBaseUrl()}${returnTo}#${fragment}`, 302)
|
||||
}
|
||||
|
||||
export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
if (!isOidcEnabled()) return
|
||||
|
||||
const secureCookie = new URL(env.OIDC_REDIRECT_URI).protocol === 'https:'
|
||||
|
||||
app.get(
|
||||
'/start',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const returnTo = safeReturnTo((request.query as Record<string, unknown> | undefined)?.returnTo)
|
||||
let start: Awaited<ReturnType<typeof startAuthorization>>
|
||||
try {
|
||||
start = await startAuthorization(returnTo)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, 'OIDC discovery failed')
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable')
|
||||
}
|
||||
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(start.pending), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau
|
||||
secure: secureCookie,
|
||||
signed: true,
|
||||
maxAge: PENDING_MAX_AGE_S,
|
||||
})
|
||||
return reply.redirect(start.url.href, 302)
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/callback',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const raw = request.cookies[PENDING_COOKIE]
|
||||
const unsigned = raw ? request.unsignCookie(raw) : null
|
||||
let pending: OidcPending | null = null
|
||||
if (unsigned?.valid && unsigned.value) {
|
||||
try {
|
||||
const parsed = pendingSchema.safeParse(JSON.parse(unsigned.value))
|
||||
pending = parsed.success ? parsed.data : null
|
||||
} catch {
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
if (!pending) {
|
||||
// Pas de connexion commencée dans ce navigateur (cookie absent, expiré ou modifié)
|
||||
return redirectToApp(reply, '/', 'oidc_error=oidc_failed')
|
||||
}
|
||||
|
||||
const returnTo = safeReturnTo(pending.returnTo)
|
||||
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
||||
|
||||
let identity: OidcIdentity
|
||||
try {
|
||||
identity = await finishAuthorization(query, pending)
|
||||
} catch (error) {
|
||||
// Refus chez le fournisseur, state ou nonce faux, ID token invalide (signature, émetteur, audience, date)
|
||||
request.log.warn({ err: error }, 'OIDC callback rejected')
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed')
|
||||
}
|
||||
|
||||
let result: AccountResult
|
||||
try {
|
||||
result = await resolveAccount(app, identity)
|
||||
} catch (error) {
|
||||
// Deux retours simultanés pour la même identité : le second retrouve le lien créé par le premier
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
result = await resolveAccount(app, identity)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if ('error' in result) {
|
||||
return redirectToApp(reply, returnTo, `oidc_error=${result.error}`)
|
||||
}
|
||||
|
||||
const handoff = app.jwt.sign(
|
||||
{ kind: 'oidc_handoff', sub: result.userId, jti: randomUUID() },
|
||||
{ expiresIn: `${HANDOFF_TTL_S}s` },
|
||||
)
|
||||
return redirectToApp(reply, returnTo, `oidc=${encodeURIComponent(handoff)}`)
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/exchange',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const parsed = exchangeSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload' })
|
||||
}
|
||||
|
||||
let payload: HandoffPayload
|
||||
try {
|
||||
payload = app.jwt.verify<HandoffPayload>(parsed.data.code)
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'invalid_handoff' })
|
||||
}
|
||||
if (payload.kind !== 'oidc_handoff' || typeof payload.exp !== 'number' || !consumeHandoff(payload.jti, payload.exp)) {
|
||||
return reply.code(401).send({ error: 'invalid_handoff' })
|
||||
}
|
||||
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true },
|
||||
})
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'invalid_handoff' })
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
token: signUserSession(app, user),
|
||||
user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) },
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -28,7 +28,14 @@ export type MediaJwtPayload = {
|
||||
accessVersion?: number
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload
|
||||
// Passage d'une connexion OIDC à l'app (60 s, à usage unique, échangé sur /auth/oidc/exchange) : jamais une session
|
||||
export type OidcHandoffJwtPayload = {
|
||||
kind: 'oidc_handoff'
|
||||
sub: string
|
||||
jti: string
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload
|
||||
|
||||
export type UserActor = {
|
||||
kind: 'user'
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('prénom du compte', () => {
|
||||
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ firstName: 'Anaïs' }),
|
||||
}))
|
||||
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
|
||||
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
})
|
||||
|
||||
it("l'inscription sans prénom le garde vide, sans erreur", async () => {
|
||||
@@ -90,7 +90,7 @@ describe('prénom du compte', () => {
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
|
||||
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
})
|
||||
|
||||
it('/me renvoie le profil du compte connecté, et refuse sans session', async () => {
|
||||
@@ -108,10 +108,10 @@ describe('prénom du compte', () => {
|
||||
})
|
||||
const token = login.json().token
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', passwordHash: 'hash' })
|
||||
const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
|
||||
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
|
||||
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
|
||||
expect(anonymous.statusCode).toBe(401)
|
||||
|
||||
@@ -155,6 +155,71 @@ describe('DELETE /auth/me', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
describe("compte sans mot de passe (né d'une connexion OIDC)", () => {
|
||||
const signedAt = async (app: FastifyInstance, minutesAgo: number) => {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign({
|
||||
kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com',
|
||||
iat: Math.floor(Date.now() / 1000) - minutesAgo * 60,
|
||||
}, { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash: null })
|
||||
})
|
||||
|
||||
it('se supprime avec son email retapé, juste après une connexion', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: ' User@Example.com ' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.user.delete).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un autre email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'autre@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'invalid_password' })
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse une session de plus de 15 minutes, même avec le bon email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 20)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'recent_sign_in_required' })
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("n'accepte pas un email à la place du mot de passe pour un compte qui en a un", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
it('rejects a wrong password without deleting anything', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
const app = createApp()
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Connexion OIDC (#115), routes et règles de compte. Le protocole lui-même (PKCE, state, nonce, signature, émetteur,
|
||||
// audience de l'ID token) est celui d'openid-client, simulé ici : il est testé de bout en bout contre un vrai
|
||||
// fournisseur à part. Ce fichier vérifie ce que Parenthèse décide avec ce que le fournisseur renvoie.
|
||||
const { prismaMock, oidcMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
userIdentity: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
oidcMock: {
|
||||
startAuthorization: vi.fn(),
|
||||
finishAuthorization: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../src/lib/oidc.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../src/lib/oidc.js')>()),
|
||||
...oidcMock,
|
||||
}))
|
||||
|
||||
const ISSUER = 'https://id.example.org'
|
||||
const pending = { state: 'state-1', nonce: 'nonce-1', codeVerifier: 'v'.repeat(43), returnTo: '/' }
|
||||
const identity = { issuer: ISSUER, subject: 'sub-1', email: 'camille@example.com', emailVerified: true, firstName: 'Camille' }
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; PASSWORD_LOGIN: boolean; OIDC_REQUIRE_VERIFIED_EMAIL: boolean }
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.OIDC_ISSUER = ISSUER
|
||||
process.env.OIDC_CLIENT_ID = 'parenthese'
|
||||
process.env.OIDC_CLIENT_SECRET = 'secret'
|
||||
process.env.OIDC_REDIRECT_URI = 'https://arbre.example.org/auth/oidc/callback'
|
||||
process.env.OIDC_PROVIDER_NAME = 'Authentik'
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const key of ['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI', 'OIDC_PROVIDER_NAME']) delete process.env[key]
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.PASSWORD_LOGIN = true
|
||||
env.OIDC_REQUIRE_VERIFIED_EMAIL = true
|
||||
oidcMock.startAuthorization.mockImplementation(async (returnTo: string) => ({
|
||||
url: new URL(`${ISSUER}/authorize?state=state-1`),
|
||||
pending: { ...pending, returnTo },
|
||||
}))
|
||||
oidcMock.finishAuthorization.mockResolvedValue(identity)
|
||||
prismaMock.user.count.mockResolvedValue(1)
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue(null)
|
||||
prismaMock.userIdentity.create.mockResolvedValue({})
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-new' })
|
||||
prismaMock.$transaction.mockImplementation(async (run: (tx: typeof prismaMock) => unknown) => run(prismaMock))
|
||||
prismaMock.$executeRaw.mockResolvedValue(1)
|
||||
})
|
||||
|
||||
// Début de connexion : renvoie le cookie signé que le navigateur rapportera au retour
|
||||
async function start(returnTo = '/') {
|
||||
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}` })
|
||||
const setCookie = String(res.headers['set-cookie'])
|
||||
return { res, cookie: setCookie.split(';')[0] }
|
||||
}
|
||||
|
||||
async function callback(cookie?: string, query = '?code=abc&state=state-1') {
|
||||
return app.inject({ method: 'GET', url: `/auth/oidc/callback${query}`, headers: cookie ? { cookie } : {} })
|
||||
}
|
||||
|
||||
function fragmentOf(location: string | undefined) {
|
||||
return new URLSearchParams(String(location).split('#')[1] ?? '')
|
||||
}
|
||||
|
||||
describe('départ et retour', () => {
|
||||
it("annonce le fournisseur à l'écran de connexion", async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' } })
|
||||
})
|
||||
|
||||
it('part chez le fournisseur avec un cookie signé, httpOnly, lax, sécurisé en https', async () => {
|
||||
const { res } = await start()
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(res.headers.location).toBe(`${ISSUER}/authorize?state=state-1`)
|
||||
const setCookie = String(res.headers['set-cookie'])
|
||||
expect(setCookie).toMatch(/^parenthese_oidc=/)
|
||||
expect(setCookie).toMatch(/HttpOnly/)
|
||||
expect(setCookie).toMatch(/SameSite=Lax/)
|
||||
expect(setCookie).toMatch(/Secure/)
|
||||
})
|
||||
|
||||
it("n'accepte comme retour que la racine ou un arbre partagé (pas de redirection ouverte)", async () => {
|
||||
await start('/arbre/famille-exemple')
|
||||
expect(oidcMock.startAuthorization).toHaveBeenLastCalledWith('/arbre/famille-exemple')
|
||||
for (const bad of ['https://evil.example', '//evil.example', '/arbre/../../x', '/\\evil.example']) {
|
||||
await start(bad)
|
||||
expect(oidcMock.startAuthorization).toHaveBeenLastCalledWith('/')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuse un retour sans connexion commencée dans ce navigateur', async () => {
|
||||
const res = await callback()
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(oidcMock.finishAuthorization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuse un cookie modifié', async () => {
|
||||
const { cookie } = await start()
|
||||
const forged = cookie.replace(/state-1/, 'state-2').replace(/%22state%22%3A%22state-1/, '%22state%22%3A%22state-2')
|
||||
const tampered = forged === cookie ? `${cookie}x` : forged
|
||||
const res = await callback(tampered)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(oidcMock.finishAuthorization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("passe à openid-client l'état gardé dans le cookie, et refuse quand la vérification échoue", async () => {
|
||||
const { cookie } = await start()
|
||||
oidcMock.finishAuthorization.mockRejectedValueOnce(new Error('unexpected ID Token "aud" (audience) claim value'))
|
||||
const res = await callback(cookie)
|
||||
expect(oidcMock.finishAuthorization).toHaveBeenCalledWith('?code=abc&state=state-1', expect.objectContaining({ state: 'state-1', nonce: 'nonce-1' }))
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(String(res.headers['set-cookie'])).toMatch(/parenthese_oidc=;/)
|
||||
})
|
||||
|
||||
it('revient sur l’arbre partagé demandé au départ', async () => {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
const { cookie } = await start('/arbre/famille-exemple')
|
||||
const res = await callback(cookie)
|
||||
expect(String(res.headers.location)).toMatch(/^https:\/\/arbre\.example\.org\/arbre\/famille-exemple#oidc=/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compte relié, créé ou refusé', () => {
|
||||
it('identité déjà reliée : connexion, même si le fournisseur ne dit plus l’email vérifié', async () => {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false, email: null })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('email vérifié qui a déjà un compte : relie le compte, ne le duplique pas', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-7', issuer: ISSUER, subject: 'sub-1' } })
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it("email non vérifié : ni relié ni créé (sinon n'importe qui prendrait le compte de cette adresse)", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_unverified')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('OIDC_REQUIRE_VERIFIED_EMAIL=false accepte un email non vérifié', async () => {
|
||||
env.OIDC_REQUIRE_VERIFIED_EMAIL = false
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.user.create).toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('OIDC_REQUIRE_VERIFIED_EMAIL=false ne relie jamais un compte existant sur un email non vérifié', async () => {
|
||||
env.OIDC_REQUIRE_VERIFIED_EMAIL = false
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_unverified')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sans email du tout : refus clair', async () => {
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: null })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_missing')
|
||||
})
|
||||
|
||||
it('compte neuf sans mot de passe, identité reliée, quand l’instance en accepte', async () => {
|
||||
const { cookie } = await start()
|
||||
await callback(cookie)
|
||||
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: 'camille@example.com',
|
||||
passwordHash: null,
|
||||
identities: { create: { issuer: ISSUER, subject: 'sub-1' } },
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('inscription fermée : le fournisseur ne crée pas de compte (#113)', async () => {
|
||||
env.ALLOW_REGISTRATION = false
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('registration_closed')
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inscription fermée : un compte existant se connecte toujours par le fournisseur', async () => {
|
||||
env.ALLOW_REGISTRATION = false
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('inscription fermée sur une instance vide : le premier compte naît par le fournisseur', async () => {
|
||||
env.ALLOW_REGISTRATION = false
|
||||
prismaMock.user.count.mockResolvedValue(0)
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.$executeRaw).toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('échange du jeton de passage', () => {
|
||||
async function handoff() {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
return fragmentOf(res.headers.location).get('oidc') as string
|
||||
}
|
||||
|
||||
it('donne une session une seule fois', async () => {
|
||||
const code = await handoff()
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: 'camille@example.com', firstName: 'Camille', passwordHash: null })
|
||||
const first = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code } })
|
||||
expect(first.statusCode).toBe(200)
|
||||
expect(first.json().user).toEqual({ id: 'user-9', email: 'camille@example.com', firstName: 'Camille', hasPassword: false })
|
||||
|
||||
const second = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code } })
|
||||
expect(second.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("le jeton de passage n'ouvre aucune session, et une session n'est pas un jeton de passage", async () => {
|
||||
const code = await handoff()
|
||||
const asBearer = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${code}` } })
|
||||
expect(asBearer.statusCode).toBe(401)
|
||||
|
||||
const session = app.jwt.sign({ kind: 'user', sub: 'user-9', jti: 'j', userId: 'user-9', email: 'camille@example.com' })
|
||||
const asCode = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code: session } })
|
||||
expect(asCode.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connexion par le fournisseur seulement (PASSWORD_LOGIN=false)', () => {
|
||||
it('refuse connexion et inscription par mot de passe, et le dit à l’écran', async () => {
|
||||
env.PASSWORD_LOGIN = false
|
||||
const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { email: 'a@example.com', password: 'motdepasse1' } })
|
||||
const register = await app.inject({ method: 'POST', url: '/auth/register', payload: { email: 'a@example.com', password: 'motdepasse1' } })
|
||||
expect(login.statusCode).toBe(403)
|
||||
expect(login.json()).toEqual({ error: 'password_login_disabled' })
|
||||
expect(register.statusCode).toBe(403)
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().passwordLogin).toBe(false)
|
||||
})
|
||||
|
||||
it('un compte sans mot de passe ne se connecte jamais par mot de passe', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: 'camille@example.com', firstName: null, passwordHash: null })
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { email: 'camille@example.com', password: 'nimportequoi' } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('journaux', () => {
|
||||
it("le code d'autorisation du retour n'est pas journalisé", async () => {
|
||||
const { redactUrl } = await import('../src/app.js')
|
||||
expect(redactUrl('/auth/oidc/callback?code=secret-code&state=s1')).toBe('/auth/oidc/callback?code=[masqué]&state=s1')
|
||||
})
|
||||
})
|
||||
@@ -96,14 +96,14 @@ describe('inscription fermée', () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ registration: false })
|
||||
expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null })
|
||||
})
|
||||
|
||||
it('instance neuve : le premier compte se crée, sous verrou', async () => {
|
||||
prismaMock.user.count.mockResolvedValue(0)
|
||||
|
||||
const options = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
expect(options.json()).toEqual({ registration: true })
|
||||
expect(options.json()).toMatchObject({ registration: true })
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('inscription ouverte (par défaut)', () => {
|
||||
env.ALLOW_REGISTRATION = true
|
||||
|
||||
const options = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
expect(options.json()).toEqual({ registration: true })
|
||||
expect(options.json()).toMatchObject({ registration: true })
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
|
||||
|
||||
|
||||
+42
-7
@@ -52,7 +52,7 @@ import {
|
||||
uploadPersonMedia,
|
||||
} from './api/treeApi'
|
||||
import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
|
||||
import { fetchAuthOptions } from './api/authApi'
|
||||
import { fetchAuthOptions, oidcStartUrl } from './api/authApi'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
|
||||
import { useMediaManager } from './hooks/useMediaManager'
|
||||
@@ -472,6 +472,19 @@ function buildContribChangesList(draft = {}) {
|
||||
return items
|
||||
}
|
||||
|
||||
|
||||
// Retour du fournisseur OIDC : #oidc=<jeton de passage> ou #oidc_error=<code>. Lu une seule fois au chargement de
|
||||
// la page puis effacé de l'adresse (historique, partage d'écran).
|
||||
function readOidcReturn() {
|
||||
if (typeof window === 'undefined') return null
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
if (!params.has('oidc') && !params.has('oidc_error')) return null
|
||||
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`)
|
||||
return { code: params.get('oidc'), error: params.get('oidc_error') }
|
||||
}
|
||||
const OIDC_RETURN = readOidcReturn()
|
||||
let oidcSignIn = null
|
||||
|
||||
function App() {
|
||||
const auth = useAuth()
|
||||
const tree = useTreeAccess()
|
||||
@@ -530,8 +543,10 @@ function App() {
|
||||
})
|
||||
const [treeNotFound, setTreeNotFound] = useState(false)
|
||||
const [accountEntryMode, setAccountEntryMode] = useState('login')
|
||||
// Inscription fermée sur l'instance (ALLOW_REGISTRATION=false, #113) : plus de « Créer un compte » nulle part
|
||||
const [registrationOpen, setRegistrationOpen] = useState(true)
|
||||
// Options de l'instance. Inscription fermée (ALLOW_REGISTRATION=false, #113) ou mot de passe désactivé
|
||||
// (PASSWORD_LOGIN=false, #115) : plus de « Créer un compte » nulle part. oidc : le bouton du fournisseur.
|
||||
const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null })
|
||||
const registrationOpen = authOptions.registration !== false && authOptions.passwordLogin !== false
|
||||
const [treeWizardVisible, setTreeWizardVisible] = useState(false)
|
||||
const [treeWizardLoading, setTreeWizardLoading] = useState(false)
|
||||
const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false)
|
||||
@@ -2185,11 +2200,27 @@ function App() {
|
||||
let registrationAllowed = true
|
||||
const authOptionsReady = fetchAuthOptions()
|
||||
.then((options) => {
|
||||
registrationAllowed = options?.registration !== false
|
||||
if (active) setRegistrationOpen(registrationAllowed)
|
||||
registrationAllowed = options?.registration !== false && options?.passwordLogin !== false
|
||||
if (active && options) setAuthOptions(options)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
// Retour du fournisseur OIDC (lu une fois au chargement, voir OIDC_RETURN). Le jeton de passage ne sert qu'une
|
||||
// fois : un second passage du démarrage (React en développement) attend le même échange.
|
||||
if (OIDC_RETURN) {
|
||||
oidcSignIn ??= OIDC_RETURN.code ? auth.handleOidcExchange(OIDC_RETURN.code) : Promise.resolve(false)
|
||||
const signedIn = await oidcSignIn
|
||||
if (!active) return
|
||||
if (!signedIn) {
|
||||
if (OIDC_RETURN.error) auth.setAccountError(getAccountErrorMessage({ payload: { error: OIDC_RETURN.error } }))
|
||||
await authOptionsReady
|
||||
if (!active) return
|
||||
setAccountEntryMode('login')
|
||||
tree.setBootState('account')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const slug = getSlugFromPathname()
|
||||
let resolvedTreeId = TREE_ID_FROM_ENV
|
||||
let resolvedTreeName = ''
|
||||
@@ -2733,8 +2764,8 @@ function App() {
|
||||
}
|
||||
|
||||
// Suppression du compte : même remise à zéro que la déconnexion une fois la session effacée.
|
||||
const handleDeleteAccount = async (password) => {
|
||||
const errorMessage = await auth.handleDeleteAccount(password)
|
||||
const handleDeleteAccount = async (confirmation) => {
|
||||
const errorMessage = await auth.handleDeleteAccount(confirmation)
|
||||
if (errorMessage) {
|
||||
return errorMessage
|
||||
}
|
||||
@@ -3001,6 +3032,10 @@ function App() {
|
||||
activeTreeId={tree.treeContext.treeId}
|
||||
defaultMode={accountEntryMode}
|
||||
canRegister={registrationOpen}
|
||||
passwordLogin={authOptions.passwordLogin !== false}
|
||||
oidcName={authOptions.oidc?.name || ''}
|
||||
onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(getSlugFromPathname() ? window.location.pathname : '/')) : undefined}
|
||||
hasPassword={auth.userAuth.user?.hasPassword !== false}
|
||||
pendingTreeName={pendingSharedTreeName}
|
||||
onLogin={handleLogin}
|
||||
onRegister={handleRegister}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from './client'
|
||||
import { API_BASE_URL, apiRequest } from './client'
|
||||
|
||||
export async function loginUser(email, password) {
|
||||
const data = await apiRequest('/auth/login', {
|
||||
@@ -29,13 +29,14 @@ export async function logoutUser(token) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAccount(token, password) {
|
||||
// confirmation : { password } pour un compte à mot de passe, { confirmEmail } pour un compte né d'une connexion OIDC
|
||||
export async function deleteAccount(token, confirmation) {
|
||||
const data = await apiRequest('/auth/me', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify(confirmation),
|
||||
})
|
||||
|
||||
return data
|
||||
@@ -76,7 +77,20 @@ export async function updateLastOpenedTree(token, payload) {
|
||||
return data?.lastTree || null
|
||||
}
|
||||
|
||||
// Options de l'instance : inscription ouverte ou fermée (ALLOW_REGISTRATION côté API)
|
||||
// Options de l'instance : inscription ouverte ou fermée, connexion par mot de passe, fournisseur OIDC
|
||||
export async function fetchAuthOptions() {
|
||||
return apiRequest('/auth/options', { method: 'GET' })
|
||||
}
|
||||
|
||||
// Adresse qui part chez le fournisseur OIDC ; returnTo ramène sur l'arbre partagé ouvert (validé par l'API)
|
||||
export function oidcStartUrl(returnTo = '/') {
|
||||
return `${API_BASE_URL}/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
// Jeton de passage reçu au retour du fournisseur, échangé une seule fois contre une session
|
||||
export async function exchangeOidcCode(code) {
|
||||
return apiRequest('/auth/oidc/exchange', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronRight, KeyRound, LogOut, Plus, Sparkles, X } from 'lucide-react'
|
||||
import { ChevronRight, KeyRound, LogIn, LogOut, Plus, Sparkles, X } from 'lucide-react'
|
||||
import logoUrl from '../assets/parenthese-logo.svg?url'
|
||||
import './AccountScreens.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
@@ -24,6 +24,10 @@ const AccountDashboard = ({
|
||||
activeTreeId,
|
||||
defaultMode = 'login',
|
||||
canRegister = true,
|
||||
passwordLogin = true,
|
||||
oidcName = '',
|
||||
onOidcLogin,
|
||||
hasPassword = true,
|
||||
pendingTreeName = '',
|
||||
onLogin,
|
||||
onRegister,
|
||||
@@ -63,7 +67,7 @@ const AccountDashboard = ({
|
||||
|
||||
setDeleting(true)
|
||||
setDeleteError('')
|
||||
const errorMessage = await onDeleteAccount(deletePassword)
|
||||
const errorMessage = await onDeleteAccount(hasPassword ? { password: deletePassword } : { confirmEmail: deletePassword })
|
||||
setDeleting(false)
|
||||
|
||||
if (errorMessage) {
|
||||
@@ -134,67 +138,82 @@ const AccountDashboard = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="pz-auth-form" onSubmit={handleAuthSubmit}>
|
||||
{isRegister && (
|
||||
{/* Fournisseur d'identité de l'instance (OIDC, #115) : il connecte, et crée le compte si l'instance en accepte */}
|
||||
{onOidcLogin && (
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block account-oidc" onClick={onOidcLogin} disabled={loading}>
|
||||
<LogIn size={16} aria-hidden="true" />
|
||||
{t('Se connecter avec {name}', { name: oidcName })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sans formulaire à mot de passe, le refus du fournisseur s'affiche ici */}
|
||||
{!passwordLogin && errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
|
||||
{onOidcLogin && passwordLogin && <div className="pz-divider">{t('ou')}</div>}
|
||||
|
||||
{passwordLogin && (
|
||||
<form className="pz-auth-form" onSubmit={handleAuthSubmit}>
|
||||
{isRegister && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountFirstName">{t('Prénom')}</label>
|
||||
<input
|
||||
id="accountFirstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Camille"
|
||||
value={firstName}
|
||||
maxLength={100}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountFirstName">{t('Prénom')}</label>
|
||||
<label htmlFor="accountEmail">{t('Email')}</label>
|
||||
<input
|
||||
id="accountFirstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Camille"
|
||||
value={firstName}
|
||||
maxLength={100}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
id="accountEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('camille@exemple.fr')}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountEmail">{t('Email')}</label>
|
||||
<input
|
||||
id="accountEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('camille@exemple.fr')}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountPassword">{t('Mot de passe')}</label>
|
||||
<input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
{isRegister && <p className="pz-hint">{t('8 caractères minimum.')}</p>}
|
||||
</div>
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountPassword">{t('Mot de passe')}</label>
|
||||
<input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
{isRegister && <p className="pz-hint">{t('8 caractères minimum.')}</p>}
|
||||
</div>
|
||||
{errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
|
||||
{errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
<button type="submit" className="pz-btn pz-btn--primary pz-btn--block" disabled={!canSubmitAuth}>
|
||||
<PzBusy busy={loading} busyLabel={isRegister ? t('Création du compte') : t('Connexion')}>{isRegister ? t('Créer mon compte') : t('Se connecter')}</PzBusy>
|
||||
</button>
|
||||
|
||||
<button type="submit" className="pz-btn pz-btn--primary pz-btn--block" disabled={!canSubmitAuth}>
|
||||
<PzBusy busy={loading} busyLabel={isRegister ? t('Création du compte') : t('Connexion')}>{isRegister ? t('Créer mon compte') : t('Se connecter')}</PzBusy>
|
||||
</button>
|
||||
|
||||
{isRegister && (
|
||||
<p className="pz-small pz-auth-legal">
|
||||
{richText(
|
||||
t('En créant un compte, vous acceptez {slot}.'),
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noopener noreferrer">
|
||||
{t('la façon dont Parenthèse traite vos données')}
|
||||
</a>,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{isRegister && (
|
||||
<p className="pz-small pz-auth-legal">
|
||||
{richText(
|
||||
t('En créant un compte, vous acceptez {slot}.'),
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noopener noreferrer">
|
||||
{t('la façon dont Parenthèse traite vos données')}
|
||||
</a>,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="pz-divider">{t('ou')}</div>
|
||||
|
||||
@@ -328,11 +347,12 @@ const AccountDashboard = ({
|
||||
|
||||
<form className="pz-auth-form" onSubmit={handleDeleteSubmit}>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountDeletePassword">{t('Mot de passe actuel')}</label>
|
||||
{/* Compte né d'une connexion OIDC, sans mot de passe : il retape son adresse email */}
|
||||
<label htmlFor="accountDeletePassword">{hasPassword ? t('Mot de passe actuel') : t('Votre adresse email, pour confirmer')}</label>
|
||||
<input
|
||||
id="accountDeletePassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
type={hasPassword ? 'password' : 'email'}
|
||||
autoComplete={hasPassword ? 'current-password' : 'off'}
|
||||
value={deletePassword}
|
||||
onChange={(event) => setDeletePassword(event.target.value)}
|
||||
disabled={deleting}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AccountDashboard from './AccountDashboard.jsx'
|
||||
import { getAccountDeletionErrorMessage, getAccountErrorMessage } from '../utils/errorMessages.js'
|
||||
|
||||
// Connexion par un fournisseur OIDC (#115) : bouton du fournisseur, formulaire à mot de passe gardé ou retiré
|
||||
const noop = () => {}
|
||||
const render = (props) => renderToStaticMarkup(
|
||||
<AccountDashboard authenticated={false} trees={[]} onLogin={noop} onRegister={noop} onBackToAccess={noop} {...props} />,
|
||||
).replaceAll(''', "'")
|
||||
|
||||
describe('écran de connexion avec un fournisseur OIDC', () => {
|
||||
it('sans fournisseur, rien ne change', () => {
|
||||
const html = render({})
|
||||
expect(html).not.toContain('Se connecter avec')
|
||||
expect(html).toContain('id="accountPassword"')
|
||||
})
|
||||
|
||||
it('fournisseur et mot de passe : le bouton du fournisseur, puis le formulaire', () => {
|
||||
const html = render({ onOidcLogin: noop, oidcName: 'Authentik' })
|
||||
expect(html).toContain('Se connecter avec Authentik')
|
||||
expect(html).toContain('id="accountPassword"')
|
||||
expect(html.indexOf('Se connecter avec Authentik')).toBeLessThan(html.indexOf('id="accountEmail"'))
|
||||
})
|
||||
|
||||
it('mot de passe désactivé : le bouton du fournisseur seul, ni formulaire ni création de compte', () => {
|
||||
const html = render({ onOidcLogin: noop, oidcName: 'Authentik', passwordLogin: false, canRegister: false, defaultMode: 'register' })
|
||||
expect(html).toContain('Se connecter avec Authentik')
|
||||
expect(html).not.toContain('id="accountEmail"')
|
||||
expect(html).not.toContain('id="accountPassword"')
|
||||
expect(html).not.toContain('Créer un compte')
|
||||
expect(html).toContain("J'ai reçu un lien de famille")
|
||||
})
|
||||
|
||||
it('mot de passe désactivé : le refus du fournisseur reste visible', () => {
|
||||
const html = render({ onOidcLogin: noop, oidcName: 'Authentik', passwordLogin: false, errorMessage: "La connexion n'a pas abouti. Réessayez." })
|
||||
expect(html).toContain("La connexion n'a pas abouti. Réessayez.")
|
||||
})
|
||||
|
||||
it('suppression d’un compte sans mot de passe : email différent, ou connexion trop ancienne', () => {
|
||||
expect(getAccountDeletionErrorMessage({ status: 403, payload: { error: 'invalid_password' } }, { confirmEmail: 'x@example.com' }))
|
||||
.toBe("Cette adresse n'est pas celle du compte.")
|
||||
expect(getAccountDeletionErrorMessage({ status: 403, payload: { error: 'recent_sign_in_required' } }, { confirmEmail: 'x@example.com' }))
|
||||
.toMatch(/reconnectez-vous/)
|
||||
expect(getAccountDeletionErrorMessage({ status: 403 }, { password: 'x' })).toBe('Mot de passe incorrect.')
|
||||
})
|
||||
|
||||
it('chaque refus au retour du fournisseur a sa phrase', () => {
|
||||
for (const code of ['oidc_failed', 'oidc_unavailable', 'oidc_email_unverified', 'oidc_email_missing', 'password_login_disabled', 'invalid_handoff']) {
|
||||
const message = getAccountErrorMessage({ payload: { error: code } })
|
||||
expect(message).not.toBe("Erreur backend. Vérifiez que l'API et la base sont lancées.")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
deleteAccount,
|
||||
exchangeOidcCode,
|
||||
fetchAccountProfile,
|
||||
fetchLastOpenedTree,
|
||||
loginUser,
|
||||
@@ -72,12 +73,17 @@ export function useAuth() {
|
||||
return { trees, lastOpenedTree: lastTree }
|
||||
}
|
||||
|
||||
const handleLogin = async (email, password) => {
|
||||
const handleLogin = (email, password) => signIn(() => loginUser(email, password))
|
||||
|
||||
// Retour d'une connexion OIDC : le jeton de passage devient une session, comme une connexion par mot de passe
|
||||
const handleOidcExchange = (code) => signIn(() => exchangeOidcCode(code))
|
||||
|
||||
const signIn = async (request) => {
|
||||
setAccountLoading(true)
|
||||
setAccountError('')
|
||||
|
||||
try {
|
||||
const result = await loginUser(email, password)
|
||||
const result = await request()
|
||||
const storedProfile = loadUserProfile()
|
||||
const mergedUser = {
|
||||
...(storedProfile && storedProfile.email === result.user?.email ? storedProfile : {}),
|
||||
@@ -162,16 +168,16 @@ export function useAuth() {
|
||||
}
|
||||
|
||||
// Renvoie null en cas de succès (session locale effacée), sinon un message d'erreur affichable.
|
||||
const handleDeleteAccount = async (password) => {
|
||||
const handleDeleteAccount = async (confirmation) => {
|
||||
const token = userAuth.token
|
||||
if (!token) {
|
||||
return errorMessages.getAccountDeletionErrorMessage({ status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAccount(token, password)
|
||||
await deleteAccount(token, confirmation)
|
||||
} catch (error) {
|
||||
return errorMessages.getAccountDeletionErrorMessage(error)
|
||||
return errorMessages.getAccountDeletionErrorMessage(error, confirmation)
|
||||
}
|
||||
|
||||
clearStoredUserToken()
|
||||
@@ -203,6 +209,7 @@ export function useAuth() {
|
||||
loadAccountTrees,
|
||||
handleLogin,
|
||||
handleRegister,
|
||||
handleOidcExchange,
|
||||
handleLogout,
|
||||
handleDeleteAccount,
|
||||
restoreUserToken,
|
||||
|
||||
@@ -44,6 +44,15 @@ export default {
|
||||
"Vous avez déjà un compte ?": "Already have an account?",
|
||||
"Pas encore de compte ?": "Don't have an account yet?",
|
||||
"Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.": "This instance no longer accepts new accounts. Ask the person who hosts it.",
|
||||
"Se connecter avec {name}": "Sign in with {name}",
|
||||
"Pour supprimer le compte, reconnectez-vous avec votre fournisseur de connexion, puis recommencez dans le quart d’heure.": "To delete the account, sign in again with your sign-in provider, then try again within fifteen minutes.",
|
||||
"Cette adresse n'est pas celle du compte.": "This isn't the account's email address.",
|
||||
"Votre adresse email, pour confirmer": "Your email address, to confirm",
|
||||
"Sur cette instance, la connexion passe par le bouton du fournisseur.": "On this instance, sign in with the provider button.",
|
||||
"Votre fournisseur de connexion n'indique pas que votre adresse email est vérifiée. Vérifiez-la chez lui, puis réessayez.": "Your sign-in provider doesn't say your email address is verified. Verify it there, then try again.",
|
||||
"Votre fournisseur de connexion n'a pas donné d'adresse email. La personne qui héberge l'instance doit autoriser le partage de l'email.": "Your sign-in provider didn't share an email address. The person who hosts this instance needs to allow it.",
|
||||
"Le fournisseur de connexion ne répond pas. Réessayez dans un moment.": "The sign-in provider isn't responding. Try again in a moment.",
|
||||
"La connexion n'a pas abouti. Réessayez.": "Signing in didn't work. Try again.",
|
||||
"Me connecter": "Sign in",
|
||||
"Vos *arbres*": "Your *trees*",
|
||||
"Ouvrez un arbre, ou commencez celui d'une autre branche de la famille.": "Open a tree, or start one for another branch of the family.",
|
||||
|
||||
@@ -16,7 +16,16 @@ export function getShareErrorMessage(error) {
|
||||
return t('Connexion backend impossible. Vérifiez API/DB puis réessayez.')
|
||||
}
|
||||
|
||||
export function getAccountDeletionErrorMessage(error) {
|
||||
export function getAccountDeletionErrorMessage(error, confirmation = {}) {
|
||||
// Compte sans mot de passe (connexion OIDC) : l'email retapé, et une connexion récente chez le fournisseur
|
||||
if (error?.payload?.error === 'recent_sign_in_required') {
|
||||
return t('Pour supprimer le compte, reconnectez-vous avec votre fournisseur de connexion, puis recommencez dans le quart d’heure.')
|
||||
}
|
||||
|
||||
if (error?.status === 403 && confirmation.confirmEmail !== undefined) {
|
||||
return t("Cette adresse n'est pas celle du compte.")
|
||||
}
|
||||
|
||||
if (error?.status === 403) {
|
||||
return t('Mot de passe incorrect.')
|
||||
}
|
||||
@@ -59,6 +68,26 @@ export function getAccountErrorMessage(error) {
|
||||
return t("L'arbre n'a plus de place pour de nouveaux souvenirs. Son propriétaire peut en supprimer.")
|
||||
}
|
||||
|
||||
if (errorCode === 'password_login_disabled') {
|
||||
return t('Sur cette instance, la connexion passe par le bouton du fournisseur.')
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_email_unverified') {
|
||||
return t("Votre fournisseur de connexion n'indique pas que votre adresse email est vérifiée. Vérifiez-la chez lui, puis réessayez.")
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_email_missing') {
|
||||
return t("Votre fournisseur de connexion n'a pas donné d'adresse email. La personne qui héberge l'instance doit autoriser le partage de l'email.")
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_unavailable') {
|
||||
return t('Le fournisseur de connexion ne répond pas. Réessayez dans un moment.')
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_failed' || errorCode === 'invalid_handoff') {
|
||||
return t("La connexion n'a pas abouti. Réessayez.")
|
||||
}
|
||||
|
||||
if (errorCode === 'registration_closed') {
|
||||
return t("Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user