Add optional Authentik (OIDC) sign-in with server-side preference sync

Signed-in users get their preferences (hidden feeds, weather location,
weather toggle) stored server-side keyed by their OIDC subject and synced
across devices. Anonymous visitors keep using browser localStorage, so the
site stays public. First sign-in migrates existing localStorage prefs up.

- config: [web.auth] section (issuer, client_id/secret, redirect, session_secret)
- storage: user_preferences table + Get/PutUserPrefs
- web/auth: OIDC code flow, HMAC-signed session cookie, CSRF state + nonce
- web/prefs_api: GET/PUT /api/preferences (auth-gated, 64KB cap)
- frontend: prefs.js sync layer seeds localStorage from server, pushes on write
- header: sign-in / account control

OIDC discovery is non-fatal at boot: if Authentik is down, Pete serves
anonymously rather than refusing to start.
This commit is contained in:
prosolis
2026-06-21 15:44:53 -07:00
parent 1a43616248
commit cbbedd9894
18 changed files with 722 additions and 32 deletions

55
internal/web/auth_test.go Normal file
View File

@@ -0,0 +1,55 @@
package web
import "testing"
func TestSignVerifyRoundTrip(t *testing.T) {
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
payload := []byte(`{"sub":"abc","exp":123}`)
tok := a.sign(payload)
got, ok := a.verify(tok)
if !ok {
t.Fatal("verify rejected a freshly signed token")
}
if string(got) != string(payload) {
t.Fatalf("payload mismatch: got %q want %q", got, payload)
}
}
func TestVerifyRejectsTamperAndForeignKey(t *testing.T) {
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
tok := a.sign([]byte(`{"sub":"abc"}`))
// Flip a byte in the payload segment.
bad := []byte(tok)
bad[0] ^= 0x01
if _, ok := a.verify(string(bad)); ok {
t.Error("verify accepted a tampered token")
}
// A different key must not validate the same token.
other := &Authenticator{secret: []byte("a-totally-different-key-16")}
if _, ok := other.verify(tok); ok {
t.Error("verify accepted a token signed with a different key")
}
if _, ok := a.verify("not-a-valid-token"); ok {
t.Error("verify accepted a malformed token")
}
}
func TestSafeNext(t *testing.T) {
cases := map[string]string{
"": "/",
"/tech": "/tech",
"/tech?page=2": "/tech?page=2",
"//evil.com": "/", // protocol-relative open redirect
"https://evil.com": "/", // absolute URL
"javascript:alert": "/", // not a path
}
for in, want := range cases {
if got := safeNext(in); got != want {
t.Errorf("safeNext(%q) = %q, want %q", in, got, want)
}
}
}