yavsc/doc/architecture/postit-oidc.md
Paul Schneider 74de6aa1d1 doc: split Architecture.md into per-topic pages
doc/Architecture.md was 436 lines and growing; this commit
extracts each non-trivial subject into its own page under
doc/architecture/ and reduces the root document to a table of
contents + transversal sections (vision, stack, admin rights).

New pages (under doc/architecture/):

- workflow-multi-parties.md : client / fournisseur /
  coordinateur roles, sous-traitance, project states, B2B/B2C,
  domaine musical production flow.
- domaine-musical.md : titres collaboratifs (formats, flux de
  production, contraintes de licence).
- licences.md : LicenceModele, CC/ODbL seed, badge projet,
  cycle de vie.
- domaines-activite.md : arbre des activites, Droit a la
  racine, DomaineActivite model.
- dictionnaires-metier.md : regle d'heritage, DictionnaireMetier
  + TermeMetier, cycle de vie d'un terme. Absorbs the previous
  doc/Dictionnaire.md draft.
- offres-frontmatter.md : ClasseFormulaire / ClasseDevis,
  OffreFournisseur, Demande, parsing YamlDotNet (introduit dans
  4034c399 Front matters). Absorbs the previous
  doc/Formulaires-devis.md and doc/Demande.md fragments.
- postit-oidc.md : documentation du client desktop PostIt,
  custom URI scheme (RFC 8252 §7.1), composants partages,
  plateformes, UX observable, persistance et reprise au boot,
  garanties testees.

Architecture.md (436 -> 66 lines) keeps the vision, the stack
overview, the admin rights section, and a TOC table pointing at
each detail page. The "A documenter ensuite" backlog is kept
at the end.

Cross-links are relative: from Architecture.md the links go
architecture/<page>.md; from inside doc/architecture/ they go
../Architecture.md or <sibling>.md.
2026-06-27 13:45:31 +01:00

6.7 KiB

PostIt — Client desktop et authentification OIDC

Récapitulatif : PostIt est un client desktop Avalonia 12 multi-cible (Desktop, Android, Browser/WASM) qui consomme l'API Yavsc.Org. Authentification public client + PKCE via custom URI scheme (RFC 8252 §7.1) — pas de loopback HTTP. Détail dans cette page, racine de l'architecture : Architecture.md.

Rôle

PostIt est un client desktop (Avalonia 12, multi-cible : Desktop Linux/Windows, Android, Browser/WASM) qui consomme l'API Yavsc.Org pour les opérations CRUD du blog et d'autres ressources de l'utilisateur. C'est un public client : pas de secret, le secret est remplacé par PKCE.

Flow d'authentification

L'authentification suit RFC 8252 §7.1 (OAuth 2.0 for Native Apps — Custom URI Scheme Redirect), pas le pattern loopback historique. La séquence :

  1. L'utilisateur clique Se connecter sur LoginPage.
  2. YavscApiClient.LoginInteractiveAsync demande à OidcClient de calculer l'authorize URL et de la passer à CustomSchemeBrowser.InvokeAsync.
  3. CustomSchemeBrowser ouvre le navigateur système sur l'authorize URL via Process.Start(options.StartUrl) { UseShellExecute = true }, puis attend sur un TaskCompletionSource<string> alimenté par un named pipe PostIt.OidcCallback.
  4. L'utilisateur s'authentifie sur l'IdP. Le navigateur redirige vers postit://callback?code=***&state=….
  5. L'OS, qui a le scheme postit:// enregistré, lance une 2ᵉ instance de PostIt avec l'URL en argument.
  6. La 2ᵉ instance lit Environment.GetCommandLineArgs() avant qu'Avalonia ne boote (PostIt.Desktop.Program.Main), détecte l'URL via SchemeUrlDetector.FindCallbackUrl, l'écrit sur le named pipe puis sort par Environment.Exit(0). Aucune fenêtre n'est créée.
  7. L'instance vivante reçoit l'URL via le pipe, complète la TCS, OidcClient échange le code contre les tokens (access + refresh + id) et les persiste dans ~/.config/PostIt/tokens.json.

Pourquoi le scheme custom plutôt que loopback

Le loopback (http://127.0.0.1:7890/callback) oblige l'app à ouvrir un port TCP, à le publier comme redirect URI dans l'IdP, et à gérer la course "le navigateur revient-il à temps ?". Le scheme custom transfère la livraison du callback à l'OS : le navigateur tape sur une URL que l'OS sait router vers PostIt, pas vers un serveur HTTP.

Composants partagés (PostIt/)

Composant Rôle
Services/OidcLoginPhase Enum des étapes du flow : Idle / Discovering / OpeningBrowser / AwaitingCallback / ExchangingCode / Success / Error
Services/YavscApiClient Client HTTP de l'API Yavsc. Porte LoginInteractiveAsync(IProgress<OidcLoginPhase>) et TrySilentLoginAsync. Refresh silencieux sur 401 et sur access-token bientôt expiré.
Services/SingleInstance Named-pipe helper. TryHandOffAsync côté 2ᵉ instance, StartServerAsync côté instance vivante.
Services/CustomSchemeBrowser IBrowser OidcClient qui ouvre le système + attend le pipe.
Services/SchemeUrlDetector Détection pure, testable, du postit://callback dans argv.
Services/TokenStore Persiste RefreshTokenRecord (access/refresh/id + expiry). Mode 0600 sur POSIX.
ViewModels/SessionStatusViewModel VM du bandeau persistant Connecté/Déconnecté + Logout.
Views/SessionStatusBanner Bandeau affiché en haut de MainWindow, visible sur toutes les pages.

Plateformes (PostIt.Desktop, PostIt.Android, PostIt.Browser)

Chaque plateforme injecte son propre IBrowser via Platform.CreateBrowser :

  • Desktop : CustomSchemeBrowser + scheme postit://.
  • Android : Chrome Custom Tabs via AndroidSystemBrowser, scheme android://postit-signin (déclaré comme <activity-alias> dans AndroidManifest.xml). MainActivity est SingleTaskOnNewIntent livre l'URL à AndroidOidcCallbackSink.
  • Browser (WASM) : pas de process distinct → N/A.

UX observable

LoginPage affiche en continu la phase courante du flow (badge PhaseLabel) en plus du StatusMessage textuel :

Phase Signification
Discovering Fetch de /.well-known/openid-configuration.
OpeningBrowser Navigateur système ouvert, on attend que l'utilisateur revienne.
AwaitingCallback (idem OpeningBrowser aujourd'hui — couvre la fenêtre pipe).
ExchangingCode Trade du code contre les tokens à /connect/token.
Success / Error Issue du flow.

Si le badge reste bloqué sur OpeningBrowser, l'OS n'a jamais relancé PostIt avec l'URL postit://callback : le scheme handler n'est pas enregistré correctement.

Persistance et reprise au démarrage

Au boot, App.OnFrameworkInitializationCompleted :

  1. Construit le TokenStore (~/.config/PostIt/tokens.json) et YavscApiClient. Ce dernier charge _tokens = store.Load() dans son constructeur.
  2. Ouvre MainWindow avec HomePage comme racine.
  3. Sur l'événement Opened, appelle TrySilentLoginAsync :
    • si l'access token a encore > RefreshSkew (60 s) de vie → Success immédiat, push MainPage.
    • sinon, tente un refresh via le refresh token. Si l'IdP accepte → push MainPage. Si l'IdP rejette (révocation, vol, expiration du refresh) → _store.Clear(), retour à HomePage.

Le bandeau SessionStatusBanner reflète Api.HasValidSession en continu et expose le bouton Se déconnecter, qui appelle Api.LogoutAsync() (purge du store) puis PopToRootAsync pour ramener sur HomePage.

Garanties testées

  • LoginInteractiveAsync émet Discovering → OpeningBrowser → ExchangingCode → Success (ou Error si pas de navigateur).
  • TrySilentLoginAsync retourne false si pas de bundle sur disque, true si l'access est encore valide, true après un refresh réussi.
  • SchemeUrlDetector.FindCallbackUrl reconnaît l'URL en argv, case-insensitive, refuse les faux positifs (postit-…://), retourne le premier match de manière déterministe.

Le chemin "refresh token rejeté → purge du store" est testé manuellement ; le stub OidcStubAuthority ne sait pas encore rejeter un refresh token précis — extension future.

Voir aussi