Merge pull request #122 from IliesAllali/feat/connexion-oidc

Auto-hébergement : connexion par un fournisseur d'identité OIDC
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-20 15:34:16 +02:00
commit 504411adf3
28 files changed
+1327 -150

No files matched your search

+13
View File
@@ -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
+16
View File
@@ -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`).
+16
View File
@@ -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`).
+9
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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:
+66 -30
View File
@@ -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",
+2
View File
@@ -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;
+18 -1
View File
@@ -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
View File
@@ -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)
+44
View File
@@ -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
+125
View File
@@ -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
}
+18
View File
@@ -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
View File
@@ -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.
+214
View File
@@ -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) },
})
},
)
}
+8 -1
View File
@@ -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'
+4 -4
View File
@@ -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)
+65
View File
@@ -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()
+300
View File
@@ -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')
})
})
+3 -3
View File
@@ -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
View File
@@ -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}
+18 -4
View File
@@ -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 }),
})
}
+77 -57
View File
@@ -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('&#x27;', "'")
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.")
}
})
})
+12 -5
View File
@@ -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,
+9
View File
@@ -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.",
+30 -1
View File
@@ -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.")
}