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.
133 lines
6.7 KiB
Markdown
133 lines
6.7 KiB
Markdown
# 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.
|