@zevra/bureau-connexion
v0.1.1
Published
Connexion des applications de bureau Zevra par le navigateur du système (RFC 8252 + PKCE S256) : contrat, routes serveur, boucle locale côté app.
Readme
@zevra/bureau-connexion
Connexion des applications de bureau Zevra (Zevra Admin, ZOS, lexform) au Compte Zevra par le navigateur du système — RFC 8252 « OAuth 2.0 for Native Apps » + PKCE S256 + boucle locale 127.0.0.1. L'app n'affiche jamais l'IdP : la personne s'authentifie dans son navigateur (lien magique, clé d'accès, Google — tout y marche), le site rend un code à usage unique à l'app, l'app l'échange contre sa session.
| Morceau | Import | Rôle |
|---|---|---|
| Contrat | @zevra/bureau-connexion | Formes, PKCE, empreintes, URL, code de vérification, validation des réponses. Pur (node:crypto). |
| Navigateur | @zevra/bureau-connexion/navigateur | Ce qu'un composant client peut importer (sans node:) : chemins, redirectionLocale, destinationInterne, délai d'armement. |
| Serveur | @zevra/bureau-connexion/serveur | creerRoutesAppairage(adaptateur) → les deux routes (Request → Response). |
| Coquille | @zevra/bureau-connexion/coquille | lancerAppairage(...) : boucle locale, navigateur, échange — Electron injecté. |
Le déroulé
- L'app écoute
127.0.0.1:<port éphémère>et ouvre dans le navigateur<site>/desktop/appairer?port&defi&etat. Elle affiche un code de vérification court (ABC-DEF), dérivé de l'état. - La page exige une session (sinon la connexion du site → le SSO, puis retour ici), montre le compte, le même code, et un bouton armé après 500 ms.
- Au clic :
POST /api/desktop/appairerenregistre l'empreinte d'un code à usage unique, lié à la session du navigateur, et rend{ redirection }vers la boucle locale. La page y part (location.replace). - L'app compare l'état à temps constant, puis
POST /api/desktop/echanger{ code, verifieur }depuis une partition jetable. Le serveur consomme le code d'abord (brûlé même si le vérifieur est faux), vérifie PKCE, vérifie que la session du navigateur existe encore, puis l'adaptateur crée la session de l'app (cookie signé) ou son jeton.
Ce que la relecture de ZOS (03/10/2026) a imposé, et qui est ici d'office
- code lié à la session du navigateur → déconnecté de la plateforme, plus rien ne s'échange (constat 1) ;
- code de vérification des deux côtés + bouton armé en différé → la confirmation est liée à l'instance d'app qui a demandé (constat 2) ;
destinationInterneen « résoudre puis comparer », contrôles refusés (constat 3) ;- la session de bureau se reconnaît par un lien en base (l'adaptateur le
garde dans
delivrer), jamais par un User-Agent recopié ; - ordre de livraison : le site du canal doit servir
/desktop/appairer,/desktop/connecte,/api/desktop/appairer,/api/desktop/echangeret sa migration AVANT qu'une version de l'app en dépende (constat 5).
Brancher une app
La table (une par app, dans son schéma)
desktop_appairages
id uuid pk
user_id (type de la table user) fk user on delete cascade
session_id (type de la table session) fk session on delete cascade ← la session du NAVIGATEUR
code_hache text unique
defi text
contexte jsonb null (organisation active, équipe…)
expire_le timestamptz
utilise_le timestamptz null
session_creee_id (type de session) null fk session on delete set null ← la session de L'APP, pour la reconnaître
created_at timestamptzLe serveur (Next)
// src/lib/appairage-bureau.ts
import { creerRoutesAppairage } from '@zevra/bureau-connexion/serveur'
export const routesAppairage = creerRoutesAppairage({
appUrl: ORIGINE_PUBLIQUE,
session: async (req) => { const s = await auth.api.getSession({ headers: req.headers }); return s && { id: s.session.id, userId: s.user.id, estBureau: estSessionBureau(s.session.id) } },
enregistrer: async (d) => { purge(); insert(d) },
consommer: async (h) => update … where code_hache = h and utilise_le is null and expire_le > now() returning …,
sessionNavigateurValide: async (id) => exists session where id and expiresAt > now(),
delivrer: async (d) => { const s = insertSession(d.userId, …); lierSessionCreee(d, s.id); return { cookie: descriptionCookie(ctx.authCookies.sessionToken, valeurCookieSession(s.token, ctx.secret), s.expiresAt) } },
audit: (e) => …,
})
// app/api/desktop/appairer/route.ts → export const POST = (req) => routesAppairage.appairer(req)
// app/api/desktop/echanger/route.ts → export const POST = (req) => routesAppairage.echanger(req)La page /desktop/appairer (serveur) : lireDemandeAppairage(searchParams)
→ sans session, redirect(<connexion>?redirect=${encodeURIComponent(cheminAppairage(d))})
→ affiche le compte et codeVerification(d.etat) → le composant client POSTe
{ port, defi, etat } après DELAI_ARMEMENT_MS, puis location.replace(redirectionLocale(...)).
Une page /desktop/connecte (« c'est fait » / « annulé ») reçoit le navigateur.
La coquille (Electron, processus principal)
import { lancerAppairage } from '@zevra/bureau-connexion/coquille'
const t = await lancerAppairage({
origine: URL_SITE,
resultat: 'cookie', // 'jeton' pour une app à Bearer (lexform)
ouvrirNavigateur: (u) => shell.openExternal(u).then(() => true, () => false),
fetch: session.fromPartition('persist:x-echange', { cache: false }).fetch,
surEvenement: (e) => { if (e.type === 'attente') afficherCode(e.codeVerification) },
})
const issue = await t.issue // { type:'cookie', cookie } | { type:'jeton', jeton } | { type:'echec', raison }
if (issue.type === 'cookie') await session.fromPartition(PARTITION).cookies.set({ url: URL_SITE, name: issue.cookie.nom, value: issue.cookie.valeur, expirationDate: issue.cookie.expirationS, secure: issue.cookie.secure, httpOnly: true, sameSite: issue.cookie.sameSite })Jamais de déclenchement automatique sans geste ; une tentative à la fois ;
t.annuler() sur « Annuler » et au départ de l'app.
Ce que tiennent les tests (pnpm test)
Formes strictes (port chaîne de chiffres, défi/état/code/vérifieur), PKCE à temps
constant, code de vérification stable et lisible, destinationInterne contre
/\t/evil.example, lireRetour (Host, doublons, forme absolue), origine égale
(jamais same-site), limiteur ; serveur : parcours nominal cookie et jeton,
autre origine refusée, session d'app refusée, rejouer refusé, vérifieur faux
brûle le code, session du navigateur disparue refuse, délivrance refusée
uniforme, 400/429/503 ; coquille (vraie boucle locale) : état faux refusé sans
fermer, Host étranger refusé, annulation, refus/injoignable/session, délai,
navigateur impossible, jeton, journal sans secret.
