feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server

Aus der statischen Seite wird ein Go-Binary mit eingebetteter Oberfläche.
Grund: Apps über .env zu pflegen wurde unübersichtlich, und kosmetische
Einstellungen sollen auf jedem Gerät gleich sein.

- Anmeldung über den eigenen OIDC-Anbieter (PocketID), mit PKCE. Ohne
  Anmeldung gibt es nichts zu sehen – auch keine App-Liste.
- Sitzung als signiertes Cookie ohne Gegenstück im Server; Schreibzugriffe
  zusätzlich über ein lesbares CSRF-Cookie abgesichert
- Apps werden in der Oberfläche angelegt und bearbeitet, nicht mehr in der
  .env. Der Server prüft jeden Eintrag und meldet zurück, was er behält.
- Einstellungen liegen je Nutzer im Server; localStorage ist nur noch der
  Vorschuss, damit die Röhre beim Laden nicht in der falschen Farbe blitzt
- die Leiste holt die App-Liste aus /api/apps – freigegeben sind allein die
  Ursprünge, die selbst als App eingetragen sind
- Tests fahren einen echten OIDC-Anbieter hoch und gehen den Anmeldeweg
  einmal ganz durch

Ohne HONEYOS_OIDC_ISSUER läuft alles offen – für die Entwicklung, mit
deutlicher Warnung beim Start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 15:20:55 +02:00
1 parent 84cc5d619e
commit 283f9a1850
20 files changed
+1831 -438

No files matched your search

+388
View File
@@ -0,0 +1,388 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// Ein Anbieter zum Anfassen: signiert echte RS256-Token und prüft PKCE. Damit
// lässt sich der ganze Anmeldeweg hier durchspielen, statt ihn erst in der
// Produktion gegen PocketID zu debuggen.
type fakeIdP struct {
*httptest.Server
key *rsa.PrivateKey
clientID string
lastChallenge string // was der Client als code_challenge geschickt hat
verifierSeen string // was er beim Einlösen als code_verifier nachreicht
}
func newFakeIdP(t *testing.T, clientID string) *fakeIdP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
f := &fakeIdP{key: key, clientID: clientID}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"issuer": f.URL,
"authorization_endpoint": f.URL + "/authorize",
"token_endpoint": f.URL + "/token",
"jwks_uri": f.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
n := base64.RawURLEncoding.EncodeToString(key.N.Bytes())
e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537
writeJSON(w, 200, map[string]any{"keys": []map[string]string{
{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test", "n": n, "e": e},
}})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
f.verifierSeen = r.Form.Get("code_verifier")
writeJSON(w, 200, map[string]any{
"access_token": "at",
"token_type": "Bearer",
"id_token": f.idToken(t),
})
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Close)
return f
}
func (f *fakeIdP) idToken(t *testing.T) string {
t.Helper()
header := map[string]string{"alg": "RS256", "typ": "JWT", "kid": "test"}
claims := map[string]any{
"iss": f.URL,
"sub": "sub-123",
"aud": f.clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"name": "Marc Testfall",
"preferred_username": "marc",
"picture": "https://example.invalid/avatar.png",
}
seg := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
signing := seg(header) + "." + seg(claims)
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:])
if err != nil {
t.Fatal(err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func newTestServer(t *testing.T, issuer string) *Server {
t.Helper()
store, err := OpenStore(dataPath(t.TempDir()))
if err != nil {
t.Fatal(err)
}
cfg := Config{
PublicURL: "https://os.example.test",
SessionSecret: strings.Repeat("k", 40),
OIDCIssuer: issuer,
OIDCClientID: "honeyos",
OIDCClientSecret: "geheim",
}
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
}
// TestAnmeldeablauf geht den Weg einmal ganz durch: Umleitung zum Anbieter,
// Rückruf mit Code, Sitzungscookie, geschützter Endpunkt.
func TestAnmeldeablauf(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
// 1. Umleitung zum Anbieter — mit State und PKCE
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil))
if rec.Code != http.StatusFound {
t.Fatalf("Umleitung erwartet, bekam %d: %s", rec.Code, rec.Body)
}
loc, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
q := loc.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("PKCE fehlt: %v", q)
}
if q.Get("redirect_uri") != "https://os.example.test/api/auth/callback" {
t.Errorf("falsche Rückrufadresse: %q", q.Get("redirect_uri"))
}
state := q.Get("state")
var stateCk *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == stateCookie {
stateCk = c
}
}
if stateCk == nil {
t.Fatal("kein State-Cookie gesetzt")
}
// 2. Rückruf mit Code
req := httptest.NewRequest("GET", "/api/auth/callback?code=abc&state="+state, nil)
req.AddCookie(stateCk)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Fatalf("Rückruf scheiterte: %d → %s", rec.Code, rec.Header().Get("Location"))
}
if idp.verifierSeen == "" {
t.Error("der Client hat kein code_verifier nachgereicht")
}
var sess, csrf *http.Cookie
for _, c := range rec.Result().Cookies() {
switch c.Name {
case sessionCookie:
sess = c
case csrfCookie:
csrf = c
}
}
if sess == nil || csrf == nil {
t.Fatal("Sitzungs- oder CSRF-Cookie fehlt")
}
if !sess.HttpOnly {
t.Error("Sitzungscookie muss HttpOnly sein")
}
if csrf.HttpOnly {
t.Error("CSRF-Cookie muss lesbar sein, sonst kann die Seite es nicht mitschicken")
}
// 3. Geschützter Endpunkt kennt den Nutzer
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(sess)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("/api/me: %d", rec.Code)
}
var me struct{ Sub, Name, Picture string }
_ = json.Unmarshal(rec.Body.Bytes(), &me)
if me.Sub != "sub-123" || me.Name != "Marc Testfall" {
t.Errorf("falscher Nutzer: %+v", me)
}
if me.Picture == "" {
t.Error("Bild aus dem Token wurde nicht übernommen")
}
}
func TestOhneAnmeldungKeineDaten(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
h := newTestServer(t, idp.URL).routes()
for _, path := range []string{"/api/me", "/api/apps"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s ohne Anmeldung: %d, erwartet 401", path, rec.Code)
}
}
}
func TestGefälschtesCookieZähltNicht(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
h := newTestServer(t, idp.URL).routes()
req := httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("selbstgebautes Cookie wurde akzeptiert: %d", rec.Code)
}
}
// TestSchreibenBrauchtCSRF: das Sitzungscookie allein darf nicht reichen,
// sonst könnte eine fremde Seite im Namen des Angemeldeten schreiben.
func TestSchreibenBrauchtCSRF(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
sess, csrf := signIn(t, h)
body := `{"mode":"desktop"}`
req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body))
req.AddCookie(sess)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("ohne CSRF-Kopfzeile: %d, erwartet 403", rec.Code)
}
req = httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body))
req.AddCookie(sess)
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("mit CSRF-Kopfzeile: %d — %s", rec.Code, rec.Body)
}
// und die Einstellung überlebt
if got := s.store.User("sub-123").Settings["mode"]; got != "desktop" {
t.Errorf("Einstellung nicht gespeichert: %q", got)
}
}
func TestAppsAnlegenUndPrüfen(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
sess, csrf := signIn(t, h)
put := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(body))
req.AddCookie(sess)
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
if rec := put(`[{"id":"aether","name":"AETHER","url":"https://cloud.example.test"}]`); rec.Code != 200 {
t.Fatalf("gültige App abgelehnt: %d — %s", rec.Code, rec.Body)
}
if apps := s.store.Apps(); len(apps) != 1 || apps[0].ID != "aether" {
t.Fatalf("nicht gespeichert: %+v", apps)
}
schlecht := []struct{ name, body string }{
{"leere Adresse", `[{"id":"x","url":""}]`},
{"Adresse ohne Schema", `[{"id":"x","url":"cloud.example.test"}]`},
{"javascript:", `[{"id":"x","url":"javascript:alert(1)"}]`},
{"Kennung mit Leerzeichen", `[{"id":"mein app","url":"https://a.test"}]`},
{"Kennung doppelt", `[{"id":"a","url":"https://a.test"},{"id":"a","url":"https://b.test"}]`},
}
for _, c := range schlecht {
if rec := put(c.body); rec.Code != http.StatusBadRequest {
t.Errorf("%s wurde angenommen (%d)", c.name, rec.Code)
}
}
// nach allen Fehlversuchen steht die gültige Liste unverändert da
if apps := s.store.Apps(); len(apps) != 1 {
t.Errorf("Liste beschädigt: %+v", apps)
}
}
// TestOffenerBetrieb: ohne OIDC_ISSUER läuft alles ohne Anmeldung — das ist
// gewollt, muss aber auch wirklich funktionieren.
func TestOffenerBetrieb(t *testing.T) {
s := newTestServer(t, "")
h := s.routes()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil))
if rec.Code != 200 {
t.Fatalf("offener Betrieb verweigert: %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("PUT", "/api/settings", strings.NewReader(`{"phosphor":"green"}`)))
if rec.Code != http.StatusNoContent {
t.Fatalf("Schreiben im offenen Betrieb: %d", rec.Code)
}
if s.store.User(localSub).Settings["phosphor"] != "green" {
t.Error("Einstellung im offenen Betrieb nicht gespeichert")
}
}
// TestLeisteNurFürEingetrageneApps: die App-Liste ist zugleich die
// CORS-Freigabe. Wer nicht drinsteht, bekommt keine Kopfzeile.
func TestLeisteNurFürEingetrageneApps(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
if err := s.store.SetApps([]App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test/"}}); err != nil {
t.Fatal(err)
}
for origin, want := range map[string]string{
"https://cloud.example.test": "https://cloud.example.test",
"https://boese.example.test": "",
} {
req := httptest.NewRequest("GET", "/api/apps", nil)
req.Header.Set("Origin", origin)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != want {
t.Errorf("Origin %s: Freigabe %q, erwartet %q", origin, got, want)
}
}
}
func TestStatischeOberflächeWirdAusgeliefert(t *testing.T) {
h := newTestServer(t, "").routes()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "HoneyOS") {
t.Fatalf("Startseite fehlt: %d", rec.Code)
}
}
// signIn spielt eine Anmeldung durch und gibt die beiden Cookies zurück.
func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil))
loc, _ := url.Parse(rec.Header().Get("Location"))
var stateCk *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == stateCookie {
stateCk = c
}
}
if stateCk == nil {
t.Fatal("kein State-Cookie")
}
req := httptest.NewRequest("GET", fmt.Sprintf("/api/auth/callback?code=abc&state=%s", loc.Query().Get("state")), nil)
req.AddCookie(stateCk)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
for _, c := range rec.Result().Cookies() {
switch c.Name {
case sessionCookie:
sess = c
case csrfCookie:
csrf = c
}
}
if sess == nil || csrf == nil {
t.Fatalf("Anmeldung fehlgeschlagen: %d", rec.Code)
}
return sess, csrf
}