The desktop app
Signing the desktop app in through the browser, what it is given, and the protocol for anybody writing a client against it.
The server half is in place; the app itself is still being built. Everything on this page describes behaviour Félagi has today, and the app is written against it.
Signing in
- Choose Sign in in the app. It shows an eight-letter code,
BCDF-GHJKstyle, and opens your browser at /app/desktop/link with the code filled in. - Sign in to Félagi in that browser if you are not already — however you normally do: password, single sign-on, two-factor.
- Check that the device named on the page is the one in front of you, and that it asked a moment ago.
- Tick the workspaces the app should reach. The one you are working in is ticked already.
- Approve. Go back to the app; it notices within a couple of seconds.
Only approve a sign-in you started yourself, on your own computer. The page says so in as many words, because that is the one way this flow is attacked: nobody can guess a code, but somebody can send you one with a plausible reason and a link. If a code arrived in a message, refuse it — Deny gives the app nothing — and tell whoever runs your workspace.
A code lasts ten minutes and works once. After that, start again from the app.
Why the app never asks for your password
Because it would be the worst place to type it.
- Plenty of people do not have one. An account that signs in with single sign-on has nothing to give the app, and teaching a desktop app to drive somebody's identity provider is a project of its own.
- Two-factor would have to be rebuilt inside the app, grace periods and recovery codes included, and kept in step with the browser for ever.
- A password on a disk unlocks everything, including changing the password — the argument Working from your editor makes for tokens.
So the decision is made in the browser, where all of that already happens. The page lives inside the signed-in application, behind the same middleware as the board, so whatever stood between you and the board stood between you and Approve — and the app never has to know which of it applied.
This is the OAuth 2.0 device authorization grant (RFC 8628), the flow a television uses to sign in to a streaming service. It suits a desktop app for the same reason: the device cannot be trusted with the password, and the person already has a browser that can.
What the app is given
One ordinary API token per workspace you ticked. Not a new kind of credential: a token that names exactly one workspace is the principle the API is built on, and an app on your desk is not a reason to invent one that reaches all of them.
Each token:
- is called after the device —
Astrid’s MacBook · Félagi Desktop— and appears under Settings → API tokens, where you can revoke it like any other; - has write access, because the app is for doing the work, not reading about it;
- has no expiry. It stops working when you revoke it, when the app signs out, or when you leave the workspace — membership is checked on every request;
- acts as you, and can never do more than you can in the browser.
Signing out revokes them. The app calls DELETE /api/v1/me/token with each, so
nothing is left behind in the keychain of a machine you have stopped using.
Who can approve, and for which workspaces
Only workspaces where your role works on issues are offered. Management is not: the API refuses that role on every request, so a token for it would be a credential that cannot work. Somebody who is Management everywhere is turned away by the page with a 403 rather than offered an empty list.
The protocol
For anybody writing a client. Two endpoints, both unauthenticated — the app has
nothing to authenticate with yet — and both outside /api/v1.
1. Ask — POST /api/desktop/device
curl -X POST "$FELAGI_URL/api/desktop/device" \
-H 'Content-Type: application/json' \
-d '{"device_name": "Astrid’s MacBook"}'
device_name is required, at most 100 characters, and is what the person sees on the
approval page. Make it something they will recognise. 201:
{
"device_code": "<48 characters>",
"user_code": "BCDF-GHJK",
"verification_uri": "https://felagi.example.com/app/desktop/link",
"verification_uri_complete": "https://felagi.example.com/app/desktop/link?code=BCDF-GHJK",
"expires_in": 600,
"interval": 2
}
device_codeis the secret the app polls with. Keep it in memory; Félagi stores only a hash.user_codeis for the person. Show it large. It is eight letters from twenty consonants — no vowels, so it never spells a word, and no characters that look alike — and the page accepts it in any case, with or without the dash.verification_uri_completeis the one to open in the browser. The plainverification_uriis for showing, in case the browser does not open.- Both URLs are built from the configured
APP_URL, not from the request, so they are the address people actually use even behind a proxy.
2. Wait — POST /api/desktop/token
curl -X POST "$FELAGI_URL/api/desktop/token" \
-H 'Content-Type: application/json' \
-d '{"device_code": "<48 characters>"}'
Poll every interval seconds until something other than 202 comes back, or until
expires_in has passed.
| Status | Body | Meaning |
|---|---|---|
| 202 | {"status": "pending"} |
Nobody has answered yet. Ask again in interval seconds |
| 200 | the tokens, below | Approved. Delivered once — store them now |
| 403 | {"message": "…"} |
Refused in the browser — or every workspace chosen was lost before the app collected |
| 404 | {"message": "…"} |
Unknown, expired, or already collected. Start again from step 1 |
| 422 | validation errors | No device_code in the body |
| 429 | Polling too fast. Retry-After says how long |
The 200:
{
"status": "approved",
"user": { "id": 1, "name": "Astrid", "email": "astrid@example.com", "locale": "nb" },
"workspaces": [
{ "id": 1, "name": "Acme", "slug": "acme", "issue_prefix": "ACM", "role": "owner", "token": "fat_…" }
]
}
user.locale is nb, en or null; null means follow the operating system. The app
can change it later with PATCH /api/v1/me — see Me.
The tokens are handed out exactly once. Collecting them marks the request used in the same database statement that checks it was not, so two polls racing each other — a retry on a flaky network does this — get the tokens and a 404 between them, never two sets. A client that loses the response has to sign in again.
Membership is asked again at that moment. Minutes pass between approving and collecting; a workspace you left, or where you became Management, in those minutes is silently dropped from the list. If that empties it, the answer is 403.
404 is one answer for three things on purpose. Telling "never existed" from "expired" from "already used" would tell somebody trying device codes which of their guesses was once real.
3. Listen — GET /api/desktop/broadcasting
With a token in hand, the app asks where the websocket server is. The browser has this baked in at build time; the app is one binary for every Félagi and learns it here.
{ "key": "app-key", "host": "ws.example.com", "port": 443, "scheme": "wss" }
host, port and scheme are where a client connects (VITE_REVERB_*, falling
back to REVERB_*), which behind a proxy is not where Laravel publishes. 404
means this installation does not broadcast: the app works without live updates
rather than retrying. Private channels are then authorised at
POST /api/broadcasting/auth with the same token — see
Chat.
Limits
| Limit | Keyed on | |
|---|---|---|
POST /api/desktop/device |
10 a minute | Address |
POST /api/desktop/token |
60 a minute | Address — twice what one app polling every two seconds needs |
| Codes typed on the approval page | 10 a minute | The signed-in person |
The page throttle is what makes eight letters enough: twenty to the eighth is twenty-five billion codes, against ten guesses a minute for each account that can sign in at all.
Housekeeping
Every request expires ten minutes after it was made, answered or not. The scheduler
prunes them a day after that — model:prune for DesktopAuthorization, nightly at
02:30 — so a question about this morning's sign-in still has something to look at.
See Running it.