Clients¶
Four apps people actually touch: three Next 15 fronts and one Tauri desktop companion. Every one is an OIDC client of the Identity service — none holds a password, none signs a token. They differ mainly in who uses them and which scope they request.
| Client | Repo | User | Port | Default scope |
|---|---|---|---|---|
| Backoffice | figestió/backoffice |
accountant | 3000 |
agency |
| Customer portal | figestió/front |
customer | 3002 |
workspace |
| Xifrasoft front | xifrasoft/front |
customer | 3001 |
workspace |
| Figestió Sync | figestió/companion-app |
accountant (desktop) | 1420¹ |
agency |
¹ Vite dev-server port for the webview; the shipped app is a native binary.
How the Next fronts authenticate¶
The three web clients share one shape (the platform's "centralised token injection"
pattern). Login is a server-side OIDC dance; tokens live in httpOnly cookies; a
single middleware.ts keeps the session fresh and bails to /logout on a hard 401.
%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'13.5px','actorBkg':'#dCe9e8','actorBorder':'#4f8f8d','actorTextColor':'#15302f','actorLineColor':'#9fb0af','signalColor':'#5a6b6a','signalTextColor':'#33403f','noteBkgColor':'#eaf0dc','noteBorderColor':'#7c9a47','noteTextColor':'#27300f'}}}%%
sequenceDiagram
autonumber
participant B as Browser
participant R as /api/auth/* (route handlers)
participant IdP
participant API as Product API
B->>R: GET /api/auth/login
R-->>B: 302 to IdP /oidc/authorize<br/>(PKCE verifier + state in httpOnly cookies)
B->>IdP: authorize → hosted login → code
IdP-->>B: 302 /api/auth/callback?code&state
B->>R: callback
R->>IdP: POST /oidc/token (code + verifier)
IdP-->>R: access + refresh
R-->>B: set access/refresh httpOnly cookies → dashboard
B->>API: data (Bearer access, injected server-side)
Cookie naming matters on localhost. Cookies aren't port-scoped, so the three
fronts on localhost use distinct names to avoid clobbering each other:
| Client | Access cookie | Refresh cookie |
|---|---|---|
| Backoffice | fi-access-token |
fi-refresh-token |
| Portal | fi-portal-access-token |
fi-portal-refresh-token |
| Xifrasoft front | xs-access-token |
xs-refresh-token |
The middleware contract¶
Every front's middleware.ts implements the same rules (the platform's
canonical auth flow):
- A valid refresh token counts as "logged in"; the middleware refreshes the
access token proactively before it expires (calling
/auth/refreshwith the refresh token as Bearer). - A 401 while holding a session cookie redirects to
/logout, which clears the cookies and sends the user to/login. rethrowControlFlow()runs first in catch-all blocks so Next's redirect signal isn't swallowed by error handling.- The per-render liveness check is the chokepoint: the backoffice sidenav and the
portal layout call
/users/me; xifrasoft's layout callsgetWorkspace(). A 401 there triggers the logout redirect.
Context selection¶
After login a front may need to pick which workspace or agency to scope to:
- Backoffice lists the accountant's agencies (
GET /agencies); one → auto-select, many → a/select-agencypicker. It then upgrades to an agency-scoped token via/auth/refresh?agency_id=. - Portal and Xifrasoft front resolve a workspace: a sole workspace is
auto-selected, otherwise a
/select-workspacepicker calls/auth/refresh?workspace_id=.
Figestió backoffice¶
The accountant's control room (:3000). Manages customers, the folders assigned to
them, notification history, and the Syncthing devices. Built on the platform's
four-layer feature structure (data / domain / presentation), with presenters as
top-level 'use server' files and class instances serialised to plain objects before
crossing Server→Client. Default UI language is Catalan via next-intl (cookie locale,
no URL prefix). OIDC migration: done.
Key sections: /dashboard/customers, /dashboard/devices (self-serve Syncthing
setup), /dashboard/notifications, /dashboard/admins, /dashboard/profile.
Figestió customer portal¶
The customer's read-only window (:3002) onto the documents their accountant manages.
Slim layout, workspace-scoped, mostly server-side reads. It is the seed of the future
unified portal that will also surface the customer's xifrasoft invoices inline
(see Integration). Today it is intentionally minimal. OIDC
migration: done; product scope still narrow.
Xifrasoft front¶
The invoicing app (:3001): issued/received invoices, companies, catalog (drag-ordered
categories), templates and per-company styling, workspace + member management, and
Stripe billing. Workspace-scoped. OIDC migration: done for login / callback /
refresh / logout; some onboarding screens (register / verify-email / workspace-setup)
still call the legacy product-API auth pending fully IdP-hosted onboarding.
Figestió Sync (the companion app)¶
A Tauri 2 (Rust core) + React desktop app that hides Syncthing from non-technical accountants. They install one binary, log in through their system browser, pick a folder, and the app syncs silently in the background, living in the system tray.
Trust boundaries¶
%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'14px','lineColor':'#7d8f8e','primaryColor':'#e7efee','primaryTextColor':'#211915','primaryBorderColor':'#5f9b9a'}}}%%
flowchart LR
web["React webview<br/><small>UI only</small>"]
rust["Rust core<br/><small>Tauri commands</small>"]
st["Syncthing sidecar<br/><small>127.0.0.1</small>"]
kc["OS keychain<br/><small>tokens</small>"]
idp["IdP"]
api["figestió API"]
web -->|"invoke()"| rust
rust --> st
rust --> kc
web -->|"auth-code + PKCE<br/>(system browser + loopback)"| idp
web -->|"device claim (agency token)"| api
class web,rust,st core
class kc,idp,api ext
classDef core fill:#d9e8e7,stroke:#4f8f8d,color:#15302f;
classDef ext fill:#f3ece6,stroke:#b39a82,color:#2b211b;
The webview never touches Syncthing or the keychain directly — everything goes through
Rust invoke() commands, so the Syncthing API key is never exposed to web code.
What the Rust side does (src-tauri/)¶
- Syncthing manager (
syncthing.rs) spawns the bundled sidecar withSTNOUPGRADE=1, persists its API key + port (mode0600), and exposes folder/device REST helpers. - Tokens (
tokens.rs) wrap the OS keychain (keyring, servicecom.figestio.sync) with an in-process cache so unsigned dev builds don't prompt on every access. - OAuth listener (
oauth.rs) binds a loopback port (51789, with fallbacks), awaits the browser redirect, validatesstate, and returns the code.
The desktop OIDC flow¶
Because there's no web origin to host cookies, the companion is a public OIDC client doing auth-code + PKCE through the system browser with a localhost redirect:
- The app mints a PKCE verifier + state and reserves a loopback port via Rust
(
oauth_bind). - It opens the system browser to the IdP
/oidc/authorize. - After login, the IdP redirects to
http://127.0.0.1:51789/callback; the Rust listener captures the code (oauth_await_code). - The app exchanges the code at
/oidc/token(public client: verifier, no secret), then stores the tokens in the OS keychain. - It resolves the accountant's agency (
GET /agencies) and upgrades to an agency-scoped token via/auth/refresh?agency_id=.
PKCE supersedes the old User-Agent binding
The earlier FigestioSync/{version} User-Agent session binding is gone; PKCE +
the loopback redirect are what tie the code to this app. The IdP no longer binds
sessions to IP or User-Agent. OIDC migration: in progress (the highest-effort
client).
Setup & sync¶
The setup wizard ensures Syncthing is running, fetches the server identity
(GET /devices/server-identity), adds the server as a peer, claims the device against
the figestió API (POST /devices/claim, idempotent), and creates the synced folder.
The status screen polls folder + connection state every few seconds and offers
pause/resume; closing the window hides to the tray (sync continues) — only the tray's
Quit stops Syncthing.
Two build-time facts worth knowing: VITE_FIGESTIO_API_URL and VITE_OIDC_ISSUER_URL
are baked into the bundle (a built app talks to the URLs it was compiled with), and
the Syncthing sidecar binary is bundled per target triple. See the companion repo's
CLAUDE.md for the full build/CI story.