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.
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 :
- L'utilisateur clique Se connecter sur
LoginPage. YavscApiClient.LoginInteractiveAsyncdemande àOidcClientde calculer l'authorize URL et de la passer àCustomSchemeBrowser.InvokeAsync.CustomSchemeBrowserouvre le navigateur système sur l'authorize URL viaProcess.Start(options.StartUrl) { UseShellExecute = true }, puis attend sur unTaskCompletionSource<string>alimenté par un named pipePostIt.OidcCallback.- L'utilisateur s'authentifie sur l'IdP. Le navigateur redirige
vers
postit://callback?code=***&state=…. - L'OS, qui a le scheme
postit://enregistré, lance une 2ᵉ instance de PostIt avec l'URL en argument. - La 2ᵉ instance lit
Environment.GetCommandLineArgs()avant qu'Avalonia ne boote (PostIt.Desktop.Program.Main), détecte l'URL viaSchemeUrlDetector.FindCallbackUrl, l'écrit sur le named pipe puis sort parEnvironment.Exit(0). Aucune fenêtre n'est créée. - 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+ schemepostit://. - Android : Chrome Custom Tabs via
AndroidSystemBrowser, schemeandroid://postit-signin(déclaré comme<activity-alias>dansAndroidManifest.xml). MainActivity estSingleTask→OnNewIntentlivre 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 :
- Construit le
TokenStore(~/.config/PostIt/tokens.json) etYavscApiClient. Ce dernier charge_tokens = store.Load()dans son constructeur. - Ouvre
MainWindowavecHomePagecomme racine. - Sur l'événement
Opened, appelleTrySilentLoginAsync:- si l'access token a encore
> RefreshSkew(60 s) de vie →Successimmédiat, pushMainPage. - 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.
- si l'access token a encore
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émetDiscovering → OpeningBrowser → ExchangingCode → Success(ouErrorsi pas de navigateur).TrySilentLoginAsyncretournefalsesi pas de bundle sur disque,truesi l'access est encore valide,trueaprès un refresh réussi.SchemeUrlDetector.FindCallbackUrlreconnaî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
- Architecture.md — racine.