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.
This commit is contained in:
parent
4034c39905
commit
74de6aa1d1
11 changed files with 548 additions and 338 deletions
133
doc/architecture/postit-oidc.md
Normal file
133
doc/architecture/postit-oidc.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue