feat(login): appen kan logge ind og bære sin session som bearer

Første trin mod en iOS-app. Appen pakker den eksisterende flade ind og henter
den fra sig selv (capacitor://localhost), så den deler aldrig origin med
serveren og får aldrig cookien tilbage. Den logger derfor ind som en browser,
med adgangskode og evt. kode, men får sin session i svaret i stedet for i en
cookie og sender den som `Authorization: Bearer`.

Det er med vilje en SESSION og ikke et API-token: den står i listen over
enheder under det navn, appen giver sig selv, kan logges ud derfra, og den når
2FA og nøgler, som et token med vilje ikke gør.

- `POST /auth/login` tager `client: "app"` og `device_name`. Svaret får
  `session_token` og ingen cookie, også mens `totp_required` er sand.
- En app-session glider: hver brug flytter udløbet frem, op til
  `VERDANDE_APP_SESSION_TTL` (180 dage) ubrugt. En browser beholder sin faste
  levetid. En halvfærdig login glider ikke; den beholder sine ti minutter.
- En bearer, der ikke er et API-token, slås op som session, men kun hvis den
  blev udstedt til appen. En browsers cookie-værdi som header må ikke blive en
  anden vej forbi SameSite og CSRF-låsen.
- CSRF-låsen genkender appens origin. Det var prøven, der fandt det: selve
  login-kaldet har intet token endnu og blev afvist som cross-site. En
  hjemmeside kan ikke skrive `Origin`-headeren, og en app-login sætter ingen
  cookie, så der er ingen session for en forfalsket anmodning at ride på.

Passkeys er udenfor i første omgang: RP-ID er bundet til domænet og kræver
apple-app-site-association på hver instans.

Migration 0037: sessions.client, device_name, idle_ttl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-23 13:55:46 +02:00
co-authored by Claude Opus 5.5
parent 95e02b567b
commit 4ef050ae04
9 changed files with 527 additions and 52 deletions
+30
View File
@@ -120,6 +120,19 @@ paths:
properties:
email: { type: string, format: email }
password: { type: string }
client:
type: string
enum: [web, app]
description: |
`app` for the iOS app. Its session comes back as
`session_token` instead of a cookie and slides — every use
moves its end forward, up to `VERDANDE_APP_SESSION_TTL`
(180 days) unused. Left out, it is a browser login.
device_name:
type: string
maxLength: 100
description: Required with `client: app`; shown in the list of sessions.
example: Kristians iPhone
responses:
"200":
description: |
@@ -132,7 +145,14 @@ paths:
properties:
user: { $ref: "#/components/schemas/User" }
totp_required: { type: boolean }
session_token:
type: string
description: |
An app login only, also while `totp_required` — the app
sends it to `/auth/login/totp` too. Sent from then on as
`Authorization: Bearer`. A browser never receives it.
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/Validation" }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/login/totp:
@@ -4439,6 +4459,10 @@ components:
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
there is no separate service identity — and is refused on `/tokens`.
Or the iOS app's session, the `session_token` from an app login. That
one is a session and reaches everything a browser session does. A
browser's own session is never accepted here.
responses:
BadRequest:
description: The body could not be read.
@@ -4973,6 +4997,12 @@ components:
it so anything the summary gets wrong is still visible.
example: Chrome på macOS
user_agent: { type: string }
client:
type: string
enum: [web, app]
description: |
`app` for the iOS app, whose `device` is the name it gave itself and
whose `expires_at` moves forward each time it is used.
ip: { type: string }
created_at: { type: string, format: date-time }
last_seen_at: { type: string, format: date-time }