Files
verdande/internal/httpapi/app_session_test.go
T
kristianwindandClaude Opus 5.5 4ef050ae04 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>
2026-10-23 13:55:46 +02:00

270 lines
9.2 KiB
Go

package httpapi
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"testing"
"time"
)
// The iOS app loads its interface from itself — capacitor://localhost — so it
// never shares an origin with the server and never gets a cookie back. It signs
// in like a browser does and carries the same kind of session, as a bearer token.
// appDo is a request the way the app makes it: no cookie jar, the token in a
// header, and the browser engine reporting the request as cross-site, because to
// WKWebView it is.
func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, ts.URL+path, reader)
if err != nil {
t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Origin", "capacitor://localhost")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) {
t.Helper()
return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
"client": "app", "device_name": "Kristians iPhone",
})
}
func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := appLogin(t, ts)
if resp.StatusCode != http.StatusOK {
t.Fatalf("app login: %d %v", resp.StatusCode, body)
}
if len(resp.Cookies()) != 0 {
t.Errorf("an app login set a cookie: %v", resp.Cookies())
}
token, _ := body["session_token"].(string)
if token == "" {
t.Fatalf("no session_token in %v", body)
}
if body["user"] == nil {
t.Error("no user in the response")
}
// The token is the whole credential, from a foreign origin, reads and writes.
resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK || me["email"] != "kristian@example.dk" {
t.Fatalf("me with the app token: %d %v", resp.StatusCode, me)
}
resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body)
}
// It is a session, not an API token: it reaches what a token may not, and it
// is in the list of devices under the name the app gave itself.
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body)
}
var found bool
for _, raw := range body["sessions"].([]any) {
s := raw.(map[string]any)
if s["current"] == true {
found = true
if s["device"] != "Kristians iPhone" || s["client"] != "app" {
t.Errorf("the app's session reads %v / %v", s["device"], s["client"])
}
}
}
if !found {
t.Error("the app's own session is not marked current")
}
// And signing out ends it.
if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("logout: %d", resp.StatusCode)
}
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("the token still works after logout: %d", resp.StatusCode)
}
}
// A browser's session never leaves its httpOnly cookie. Its value replayed as a
// bearer must not work: that would turn the header into a second way past the
// SameSite and CSRF locks the cookie sits behind.
func TestABrowserSessionIsNotABearer(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var cookie string
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
if c.Name == sessionCookieInsecure {
cookie = c.Value
}
}
if cookie == "" {
t.Fatal("bootstrap left no session cookie")
}
if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode)
}
// Control: the same value is still a perfectly good cookie.
if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Errorf("control: the cookie itself: %d", resp.StatusCode)
}
}
func TestAppLoginWithASecondFactor(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{"password": "et langt kodeord"})
secret, _ := body["secret"].(string)
if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
resp, body := appLogin(t, ts)
token, _ := body["session_token"].(string)
if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" {
t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body)
}
if body["user"] != nil {
t.Error("the user came back before the code was given")
}
// Half a login reaches nothing, as in a browser, and does not slide yet.
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired {
t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body)
}
session, _, err := ts.db.SessionByToken(t.Context(), token)
if err != nil {
t.Fatal(err)
}
if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute {
t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL)
}
resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp from the app: %d %v", resp.StatusCode, body)
}
if len(resp.Cookies()) != 0 {
t.Errorf("finishing an app login set a cookie: %v", resp.Cookies())
}
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Errorf("me after the code: %d", resp.StatusCode)
}
session, _, _ = ts.db.SessionByToken(t.Context(), token)
if session.IdleTTL != 7*24*time.Hour {
t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL)
}
}
// A phone in daily use must not sign itself out; one left in a drawer must. The
// end moves forward each time the session is used, and a browser's does not.
func TestAnAppSessionSlides(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := appLogin(t, ts)
token := body["session_token"].(string)
// Pretend it was last used a while ago and is a day from ending.
soon := time.Now().Add(24 * time.Hour).Unix()
if _, err := ts.db.ExecContext(t.Context(),
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`,
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
t.Fatal(err)
}
if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("me: %d", resp.StatusCode)
}
var expires int64
if err := ts.db.QueryRowContext(t.Context(),
`SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil {
t.Fatal(err)
}
if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour {
t.Errorf("using the app session left %v on it, want about the app TTL", left)
}
// Control: a browser session used the same way keeps its fixed end.
if _, err := ts.db.ExecContext(t.Context(),
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`,
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
t.Fatal(err)
}
ts.do(t, "GET", "/api/v1/auth/me", nil)
if err := ts.db.QueryRowContext(t.Context(),
`SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil {
t.Fatal(err)
}
if expires != soon {
t.Errorf("a browser session's end moved from %d to %d", soon, expires)
}
}
func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, tc := range []struct {
name string
body map[string]string
field string
}{
{"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"},
{"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"},
} {
tc.body["email"] = "kristian@example.dk"
tc.body["password"] = "et langt kodeord"
resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode)
continue
}
if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil {
t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body)
}
}
}
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}