Compte à mot de passe : relier le fournisseur OIDC soi-même (#161)

* OIDC : un compte à mot de passe relie lui-même son identité du fournisseur (ticket à usage unique), pour passer une instance en PASSWORD_LOGIN=false sans l'enfermer dehors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Compte : « Se connecter aussi avec … » relie le fournisseur à un compte à mot de passe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* README : relier un compte à mot de passe avant PASSWORD_LOGIN=false

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 authored and GitHub committed 2026-10-26 12:34:28 +01:00
1 parent 856a80d111
commit f1b4ad8db2
13 files changed
+221 -12

No files matched your search

+1 -1
View File
@@ -97,7 +97,7 @@ OIDC_PROVIDER_NAME=Authentik # shown on
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.
`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. Accounts that already have a password are not linked to the provider by email: before switching, each owner opens their account and clicks « Se connecter aussi avec … » ("Also sign in with …"), then signs in with the provider once. `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.
+3
View File
@@ -11,6 +11,9 @@ export type OidcPending = {
nonce: string
codeVerifier: string
returnTo: string
// Rattachement demandé depuis un compte connecté (ticket de /auth/oidc/link) : l'identité du fournisseur rejoint
// ce compte, au lieu d'en chercher un par email
linkUserId?: string
}
export type OidcIdentity = {
+4 -3
View File
@@ -254,14 +254,15 @@ 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, passwordHash: true },
select: { id: true, email: true, firstName: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
})
if (!user) {
return reply.code(401).send({ error: 'authentication_required' })
}
// 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: confirmsWithPassword(user.passwordHash) } })
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe.
// oidcLinked : le compte se connecte déjà par le fournisseur ; sinon le compte propose « Relier à … »
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash), oidcLinked: (user.identities?.length ?? 0) > 0 } })
})
app.get('/me/last-tree', async (request, reply) => {
+67 -5
View File
@@ -46,8 +46,42 @@ const pendingSchema = z.object({
nonce: z.string().min(1),
codeVerifier: z.string().min(43),
returnTo: z.string(),
linkUserId: z.string().min(1).optional(),
})
// Rattacher une identité du fournisseur à un compte à mot de passe. Le compte connecté demande un ticket (court, à
// usage unique), le départ OIDC le porte, et le retour relie l'identité à ce compte. La personne prouve les deux :
// son mot de passe (session) et son compte chez le fournisseur. C'est le chemin pour passer une instance en
// PASSWORD_LOGIN=false sans enfermer dehors les comptes à mot de passe : le rattachement automatique par email reste
// refusé pour eux (resolveAccount), parce que l'inscription par mot de passe ne vérifie pas l'adresse.
const LINK_TICKET_TTL_S = 120
type LinkTicketPayload = { kind: 'oidc_link'; sub: string; jti: string; exp?: number }
const usedLinkTickets = new Map<string, number>()
function consumeLinkTicket(jti: string, expSeconds: number): boolean {
const now = Date.now()
for (const [key, expiresAt] of usedLinkTickets) {
if (expiresAt < now) usedLinkTickets.delete(key)
}
if (usedLinkTickets.has(jti)) return false
usedLinkTickets.set(jti, expSeconds * 1000)
return true
}
type LinkResult = { userId: string } | { error: 'oidc_identity_taken' | 'oidc_failed' }
async function linkIdentity(app: FastifyInstance, userId: string, identity: OidcIdentity): Promise<LinkResult> {
const user = await app.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
if (!user) return { error: 'oidc_failed' }
const linked = await app.prisma.userIdentity.findUnique({
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
select: { userId: true },
})
// Déjà reliée à ce compte : rien à faire. Reliée à un autre : jamais déplacée d'un compte à l'autre
if (linked) return linked.userId === userId ? { userId } : { error: 'oidc_identity_taken' }
await app.prisma.userIdentity.create({ data: { userId, issuer: identity.issuer, subject: identity.subject } })
return { userId }
}
const exchangeSchema = z.object({
code: z.string().min(1).max(4096),
})
@@ -114,7 +148,18 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
'/start',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
async (request, reply) => {
const returnTo = safeReturnTo((request.query as Record<string, unknown> | undefined)?.returnTo)
const query = (request.query as Record<string, unknown> | undefined) ?? {}
// Ticket de rattachement : vérifié et consommé ici, le compte voyage dans le cookie signé
let linkUserId: string | undefined
if (typeof query.link === 'string') {
let ticket: LinkTicketPayload | null = null
try { ticket = app.jwt.verify<LinkTicketPayload>(query.link) } catch { ticket = null }
if (!ticket || ticket.kind !== 'oidc_link' || typeof ticket.exp !== 'number' || !consumeLinkTicket(ticket.jti, ticket.exp)) {
return redirectToApp(reply, '/', 'oidc_error=oidc_link_expired')
}
linkUserId = ticket.sub
}
const returnTo = linkUserId ? '/' : safeReturnTo(query.returnTo)
let start: Awaited<ReturnType<typeof startAuthorization>>
try {
start = await startAuthorization(returnTo)
@@ -123,7 +168,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable')
}
reply.setCookie(PENDING_COOKIE, JSON.stringify(start.pending), {
reply.setCookie(PENDING_COOKIE, JSON.stringify(linkUserId ? { ...start.pending, linkUserId } : start.pending), {
path: '/',
httpOnly: true,
sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau
@@ -167,13 +212,13 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed')
}
let result: AccountResult
let result: AccountResult | LinkResult
try {
result = await resolveAccount(app, identity)
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : 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)
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
} else {
throw error
}
@@ -195,6 +240,23 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
},
)
// Ticket de rattachement pour le compte connecté (session par en-tête) : la navigation vers /start ne peut pas
// porter l'en-tête, le ticket le remplace, deux minutes et une seule fois
app.post(
'/link',
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const ticket = app.jwt.sign(
{ kind: 'oidc_link', sub: request.actor.userId, jti: randomUUID() },
{ expiresIn: `${LINK_TICKET_TTL_S}s` },
)
return reply.send({ ticket })
},
)
app.post(
'/exchange',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
+9 -1
View File
@@ -35,7 +35,15 @@ export type OidcHandoffJwtPayload = {
jti: string
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload
// Ticket de rattachement d'une identité OIDC au compte connecté (120 s, à usage unique, porté par /auth/oidc/start) :
// jamais une session, le décodeur de l'en-tête ne retient que les sortes user et tree_access
export type OidcLinkJwtPayload = {
kind: 'oidc_link'
sub: string
jti: string
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload | OidcLinkJwtPayload
export type UserActor = {
kind: 'user'
+1 -1
View File
@@ -111,7 +111,7 @@ describe('prénom du compte', () => {
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', hasPassword: true })
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true, oidcLinked: false })
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
expect(anonymous.statusCode).toBe(401)
+83
View File
@@ -337,6 +337,89 @@ describe('connexion par le fournisseur seulement (PASSWORD_LOGIN=false)', () =>
}, 15000)
})
describe('rattacher le fournisseur à un compte à mot de passe (avant PASSWORD_LOGIN=false)', () => {
const sessionOf = (userId: string) => app.jwt.sign({ kind: 'user', sub: userId, jti: `s-${userId}`, userId, email: 'anais@example.com' })
async function ticketFor(userId: string) {
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', headers: { authorization: `Bearer ${sessionOf(userId)}` }, remoteAddress: nextAddress() })
expect(res.statusCode).toBe(200)
return res.json().ticket as string
}
async function startLink(ticket: string) {
currentAddress = nextAddress()
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?link=${encodeURIComponent(ticket)}&returnTo=${encodeURIComponent('/arbre/abc')}`, remoteAddress: currentAddress })
return { res, cookie: String(res.headers['set-cookie']).split(';')[0] }
}
it('sans session, pas de ticket', async () => {
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', remoteAddress: nextAddress() })
expect(res.statusCode).toBe(401)
})
it("relie l'identité au compte qui a demandé le ticket, même avec un mot de passe et sans regarder l'email", async () => {
const ticket = await ticketFor('user-pw')
const { res: started, cookie } = await startLink(ticket)
expect(started.statusCode).toBe(302)
// Le retour de l'app ne vient pas de la requête : un rattachement revient toujours à la racine
expect(oidcMock.startAuthorization).toHaveBeenCalledWith('/')
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'autre@example.com', emailVerified: false })
const res = await callback(cookie)
expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-pw', issuer: ISSUER, subject: 'sub-1' } })
const code = fragmentOf(res.headers.location).get('oidc') as string
expect(app.jwt.decode<{ sub: string }>(code)?.sub).toBe('user-pw')
// Le rattachement ne passe jamais par la recherche d'un compte par email ni par la création d'un compte
expect(prismaMock.user.create).not.toHaveBeenCalled()
})
it("une identité déjà reliée à un autre compte n'est jamais déplacée", async () => {
const ticket = await ticketFor('user-pw')
const { cookie } = await startLink(ticket)
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-other' })
const res = await callback(cookie)
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_identity_taken')
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
})
it('déjà reliée à ce compte : connexion, rien de recréé', async () => {
const ticket = await ticketFor('user-pw')
const { cookie } = await startLink(ticket)
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-pw' })
const res = await callback(cookie)
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
})
it('le ticket sert une fois, et un faux ticket ou une session ne lancent rien', async () => {
const ticket = await ticketFor('user-pw')
expect((await startLink(ticket)).res.statusCode).toBe(302)
const again = await startLink(ticket)
expect(fragmentOf(again.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
const forged = await startLink('nimportequoi')
expect(fragmentOf(forged.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
const session = await startLink(sessionOf('user-pw'))
expect(fragmentOf(session.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
})
it("le ticket n'ouvre aucune session", async () => {
const ticket = await ticketFor('user-pw')
const asBearer = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${ticket}` } })
expect(asBearer.statusCode).toBe(401)
})
it('compte supprimé entre le ticket et le retour : refusé', async () => {
const ticket = await ticketFor('user-pw')
const { cookie } = await startLink(ticket)
prismaMock.user.findUnique.mockResolvedValue(null)
const res = await callback(cookie)
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
})
})
describe('journaux', () => {
it("le code d'autorisation du retour n'est pas journalisé", async () => {
const { redactUrl } = await import('../src/app.js')
+10 -1
View File
@@ -54,7 +54,7 @@ import {
uploadPersonMedia,
} from './api/treeApi'
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { fetchAuthOptions, oidcStartUrl } from './api/authApi'
import { fetchAuthOptions, oidcLinkUrl, oidcStartUrl, requestOidcLinkTicket } 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'
@@ -3181,6 +3181,15 @@ function App() {
oidcName={authOptions.oidc?.name || ''}
onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(getSlugFromPathname() ? window.location.pathname : '/')) : undefined}
hasPassword={auth.userAuth.user?.hasPassword !== false}
oidcLinked={auth.userAuth.user?.oidcLinked === true}
onLinkOidc={authOptions.oidc && auth.userAuth.user?.oidcLinked === false ? async () => {
// Relier le fournisseur au compte : le serveur donne un ticket court, le départ OIDC le porte
try {
window.location.assign(oidcLinkUrl(await requestOidcLinkTicket(auth.userAuth.token)))
} catch (error) {
auth.setAccountError(getAccountErrorMessage(error))
}
} : undefined}
pendingTreeName={pendingSharedTreeName}
onLogin={handleLogin}
onRegister={handleRegister}
+16
View File
@@ -88,6 +88,22 @@ export function oidcStartUrl(returnTo = '/') {
return `${API_BASE_URL}/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`
}
// Relier le fournisseur au compte connecté : un ticket de deux minutes, à usage unique, puis le départ OIDC qui le porte
export async function requestOidcLinkTicket(token) {
const data = await apiRequest('/auth/oidc/link', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.ticket
}
export function oidcLinkUrl(ticket) {
return `${API_BASE_URL}/auth/oidc/start?link=${encodeURIComponent(ticket)}`
}
// 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', {
@@ -29,6 +29,10 @@ const AccountDashboard = ({
oidcName = '',
onOidcLogin,
hasPassword = true,
// Compte à mot de passe sur une instance OIDC : « Se connecter aussi avec … » relie le fournisseur, pour que le compte
// reste accessible quand l'instance passe en connexion par le fournisseur seule. oidcLinked : déjà fait
onLinkOidc,
oidcLinked = false,
pendingTreeName = '',
onLogin,
onRegister,
@@ -305,7 +309,14 @@ const AccountDashboard = ({
{t('Essayer la démo')}
</button>
)}
{onLinkOidc && !oidcLinked && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm account-oidc-link" onClick={onLinkOidc} disabled={loading}>
<LogIn size={15} aria-hidden="true" />
{t('Se connecter aussi avec {name}', { name: oidcName })}
</button>
)}
</div>
{oidcLinked && oidcName && <p className="account-oidc-linked">{t('Connexion avec {name} reliée à ce compte.', { name: oidcName })}</p>}
</div>
<div className="pz-account-foot">
@@ -70,3 +70,6 @@
/* Arbre avec une icône choisie : l'emoji seul, sans la pastille de couleur de la lettre */
.pz-tree-mono--emoji { background: transparent; font-size: 22px; line-height: 1; }
.invite-tree-icon { font-size: 30px; line-height: 1; }
/* Connexion par le fournisseur reliée au compte (OIDC) */
.account-oidc-linked { margin: 8px 0 0; font: 400 13px var(--pz-sans); color: var(--pz-muted); }
+5
View File
@@ -247,4 +247,9 @@ export default {
"Aide": "Help",
"Se déconnecter": "Sign out",
"Partager l'arbre": "Share the tree",
// Relier le fournisseur OIDC à un compte à mot de passe
'Se connecter aussi avec {name}': 'Also sign in with {name}',
'Connexion avec {name} reliée à ce compte.': 'Sign-in with {name} is linked to this account.',
'Ce compte du fournisseur est déjà relié à un autre compte Parenthèse.': 'This provider account is already linked to another Parenthèse account.',
'Le lien pour relier le compte a expiré. Recommencez depuis votre compte.': 'The link to connect your account has expired. Start again from your account.',
}
+8
View File
@@ -92,6 +92,14 @@ export function getAccountErrorMessage(error) {
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_identity_taken') {
return t('Ce compte du fournisseur est déjà relié à un autre compte Parenthèse.')
}
if (errorCode === 'oidc_link_expired') {
return t('Le lien pour relier le compte a expiré. Recommencez depuis votre compte.')
}
if (errorCode === 'oidc_email_taken') {
return t("Un compte avec mot de passe existe déjà pour cette adresse. Connectez-vous avec votre email et votre mot de passe.")
}