yavsc/doc/architecture/postit-oidc.md
Paul Schneider 20a6f22ec3
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
PostIt: document the BaseAddress / pathPrefix URL convention
The previous "PostIt: fix blog API double-prefix" commit changed
DefaultPathPrefix from "api/blog" to "blog" without spelling out
the convention. Future-me (or anyone else touching ApiUrl) needs
to know that BaseAddress already terminates in /api/v1/ and that
pathPrefix is relative to that.

* BlogApiClient: add a <para> in the class summary that names the
  convention, points at the matching controller route, and
  cross-references the fix commit.
* postit-oidc.md: add a row in the "Composants partagés" table
  with the same warning, in the architectural-doc voice.
2026-07-06 21:03:10 +01:00

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/BlogApiClient Mapper DTO↔path pour la sous-API blog. Note : pathPrefix est relatif à /api/v1/ (que porte déjà BaseAddress) — ex. "blog" pour matcher [Route(APIPrefix + "/blog")]. Ne pas ré-inclure api/.
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