yavsc/doc/architecture/postit-oidc.md

133 lines
6.7 KiB
Markdown
Raw Normal View History

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
# 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](../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
`SingleTask``OnNewIntent` 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
- [Architecture.md](../Architecture.md) — racine.