From cb84e1d549fd345af2a4d2d5963e3429c3c17bf0 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:04:09 -0700 Subject: [PATCH] games: a news session that travels to the games box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preferred_username was being read from the ID token and thrown away after serving as a display-name fallback. It is the whole identity story: MAS imports it as the Matrix localpart, so it is also who the player is in the euro economy. Keep it in the session, and derive @user:server from it. The session cookie was host-only, so a sign-in on news never reached games. Widen it with an opt-in web.auth.cookie_domain — but only the session cookie: the OAuth round-trip cookie pairs with a redirect back to the host that started the login and stays where it was set. And because the redirect must return to that host, the redirect_uri is now derived per-request for hosts inside the cookie domain, with the configured URL as the fallback for anything else — a Host header we don't own is never echoed into a redirect. --- config.example.toml | 6 +++ internal/config/config.go | 6 +++ internal/web/auth.go | 77 +++++++++++++++++++++++++++-- internal/web/auth_test.go | 100 +++++++++++++++++++++++++++++++++++++- 4 files changed, 184 insertions(+), 5 deletions(-) diff --git a/config.example.toml b/config.example.toml index cf2b03d..67b3311 100644 --- a/config.example.toml +++ b/config.example.toml @@ -58,6 +58,12 @@ client_secret = "${PETE_OIDC_CLIENT_SECRET}" redirect_url = "https://news.parodia.dev/auth/callback" # HMAC key that signs the session cookie. Generate with: openssl rand -hex 32 session_secret = "${PETE_SESSION_SECRET}" +# Share the session across sibling hosts, so signing in on news.parodia.dev also +# signs you in on games.parodia.dev. This widens the cookie to every host under +# the domain, so leave it empty to keep the session host-only. Each host that +# starts a login also needs its own redirect URI registered in Authentik +# (/auth/callback) — the login round-trip returns to the host it began on. +cookie_domain = "" # Optional Web Push digests. When enabled, signed-in users can opt in (from the # feed-settings panel) to a periodic "N new stories" notification, delivered via diff --git a/internal/config/config.go b/internal/config/config.go index 0b7349a..f9df620 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -118,6 +118,12 @@ type AuthConfig struct { ClientSecret string `toml:"client_secret"` RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie + // CookieDomain widens the session cookie beyond the host that set it, so a + // sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it + // to ".parodia.dev" to share the session across every parodia.dev host — + // which is every host, including the landing site, so it is opt-in rather + // than the default. Empty keeps the cookie host-only. + CookieDomain string `toml:"cookie_domain"` } type MatrixConfig struct { diff --git a/internal/web/auth.go b/internal/web/auth.go index b4d9b6e..c868e1c 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -11,6 +11,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strings" "time" @@ -32,6 +33,7 @@ type Authenticator struct { oauth *oauth2.Config verifier *oidc.IDTokenVerifier secret []byte + domain string // cookie Domain; empty means host-only } // SessionUser is the identity carried in the signed session cookie. @@ -39,7 +41,23 @@ type SessionUser struct { Sub string `json:"sub"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` - Exp int64 `json:"exp"` + // Username is the Authentik preferred_username, which MAS imported as the + // Matrix localpart — so it is also who this person is in the game economy. + // Sessions signed before games existed don't carry it; MatrixUser returns + // "" for those and the caller sends them back through sign-in. + Username string `json:"username,omitempty"` + Exp int64 `json:"exp"` +} + +// MatrixUser maps the session to a Matrix ID on the given server name, e.g. +// "reala" on "parodia.dev" -> "@reala:parodia.dev". Empty if either half is +// missing, which callers must treat as "not identified in the economy". +func (u *SessionUser) MatrixUser(serverName string) string { + name := strings.ToLower(strings.TrimSpace(u.Username)) + if name == "" || serverName == "" { + return "" + } + return "@" + name + ":" + serverName } // Display is the friendly name shown in the header (name, else email, else sub). @@ -88,6 +106,7 @@ func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticato }, verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), secret: []byte(cfg.SessionSecret), + domain: strings.TrimSpace(cfg.CookieDomain), }, nil } @@ -150,11 +169,23 @@ func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser { return &u } +// cookieDomain is the Domain attribute for a given cookie. Only the session +// cookie is widened: the OAuth round-trip cookie stays host-only, because it +// pairs with a redirect back to the host that started the login and has no +// business being readable from anywhere else. +func (a *Authenticator) cookieDomain(name string) string { + if name == sessionCookie { + return a.domain + } + return "" +} + func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: name, Value: value, Path: "/", + Domain: a.cookieDomain(name), Expires: time.Now().Add(ttl), MaxAge: int(ttl.Seconds()), HttpOnly: true, @@ -166,10 +197,42 @@ func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", MaxAge: -1, + Domain: a.cookieDomain(name), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } +// oauthFor returns the OAuth config to use for this request. The configured +// redirect_url names one host (news), but a login that starts on games has to +// come back to games — otherwise the browser is dumped on the news site with +// its "next" path pointing at a page that lives elsewhere. So when the request +// arrives on a host inside the shared cookie domain, keep the redirect on that +// host, reusing the configured URL's scheme and path. Every host used this way +// must be registered as a redirect URI in Authentik. +func (a *Authenticator) oauthFor(r *http.Request) *oauth2.Config { + if a.domain == "" || !hostInDomain(r.Host, a.domain) { + return a.oauth + } + u, err := url.Parse(a.oauth.RedirectURL) + if err != nil || u.Host == r.Host { + return a.oauth + } + cfg := *a.oauth + cfg.RedirectURL = u.Scheme + "://" + r.Host + u.Path + return &cfg +} + +// hostInDomain reports whether host sits inside a cookie domain like +// ".parodia.dev" (which also covers the bare "parodia.dev"). +func hostInDomain(host, domain string) bool { + if i := strings.IndexByte(host, ':'); i >= 0 { + host = host[:i] // strip port + } + host = strings.ToLower(host) + bare := strings.TrimPrefix(strings.ToLower(domain), ".") + return host == bare || strings.HasSuffix(host, "."+bare) +} + // ---- handlers ------------------------------------------------------------- // handleLogin starts the OIDC authorization-code flow. @@ -182,7 +245,7 @@ func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) { } payload, _ := json.Marshal(st) a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL) - http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound) + http.Redirect(w, r, a.oauthFor(r).AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound) } // handleCallback completes the flow: validates state, exchanges the code, @@ -212,7 +275,7 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code")) + tok, err := a.oauthFor(r).Exchange(ctx, r.URL.Query().Get("code")) if err != nil { slog.Error("auth: code exchange failed", "err", err) http.Error(w, "sign-in failed", http.StatusBadGateway) @@ -247,7 +310,13 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) { if name == "" { name = claims.PreferredUsername } - u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()} + u := SessionUser{ + Sub: claims.Sub, + Name: name, + Email: claims.Email, + Username: claims.PreferredUsername, + Exp: time.Now().Add(sessionTTL).Unix(), + } sess, _ := json.Marshal(u) a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL) slog.Info("auth: user signed in", "sub", claims.Sub, "name", name) diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go index a183ec4..fe97d29 100644 --- a/internal/web/auth_test.go +++ b/internal/web/auth_test.go @@ -1,6 +1,12 @@ package web -import "testing" +import ( + "net/http/httptest" + "testing" + "time" + + "golang.org/x/oauth2" +) func TestSignVerifyRoundTrip(t *testing.T) { a := &Authenticator{secret: []byte("test-secret-key-at-least-16")} @@ -53,3 +59,95 @@ func TestSafeNext(t *testing.T) { } } } + +func TestMatrixUser(t *testing.T) { + cases := []struct { + name string + u SessionUser + want string + }{ + {"lowercased", SessionUser{Username: "Reala"}, "@reala:parodia.dev"}, + {"trimmed", SessionUser{Username: " reala "}, "@reala:parodia.dev"}, + {"old session with no username", SessionUser{Sub: "abc", Name: "Reala"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.u.MatrixUser("parodia.dev"); got != tc.want { + t.Fatalf("MatrixUser = %q, want %q", got, tc.want) + } + }) + } + if got := (&SessionUser{Username: "reala"}).MatrixUser(""); got != "" { + t.Fatalf("no server name should yield no identity, got %q", got) + } +} + +func TestHostInDomain(t *testing.T) { + cases := []struct { + host, domain string + want bool + }{ + {"games.parodia.dev", ".parodia.dev", true}, + {"news.parodia.dev:8080", ".parodia.dev", true}, + {"parodia.dev", ".parodia.dev", true}, + {"GAMES.PARODIA.DEV", ".parodia.dev", true}, + {"evil.com", ".parodia.dev", false}, + // The suffix check must not match a domain that merely ends in the + // same letters: notparodia.dev is a different site entirely. + {"notparodia.dev", ".parodia.dev", false}, + {"games.parodia.dev.evil.com", ".parodia.dev", false}, + } + for _, tc := range cases { + if got := hostInDomain(tc.host, tc.domain); got != tc.want { + t.Errorf("hostInDomain(%q, %q) = %v, want %v", tc.host, tc.domain, got, tc.want) + } + } +} + +func TestOAuthForKeepsLoginOnTheHostItStartedOn(t *testing.T) { + base := &oauth2.Config{RedirectURL: "https://news.parodia.dev/auth/callback"} + a := &Authenticator{oauth: base, domain: ".parodia.dev"} + + req := httptest.NewRequest("GET", "/auth/login", nil) + req.Host = "games.parodia.dev" + if got := a.oauthFor(req).RedirectURL; got != "https://games.parodia.dev/auth/callback" { + t.Fatalf("games login should come back to games, got %q", got) + } + + req.Host = "news.parodia.dev" + if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL { + t.Fatalf("news login should use the configured URL, got %q", got) + } + + // A Host we don't own must never be echoed back into a redirect URI. + req.Host = "evil.com" + if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL { + t.Fatalf("foreign host must fall back to the configured URL, got %q", got) + } + + // With no cookie domain configured there is nothing to share, so the + // configured redirect stands whatever the Host header says. + off := &Authenticator{oauth: base} + req.Host = "games.parodia.dev" + if got := off.oauthFor(req).RedirectURL; got != base.RedirectURL { + t.Fatalf("host-only mode must not rewrite the redirect, got %q", got) + } +} + +func TestSessionCookieIsSharedButOAuthCookieIsNot(t *testing.T) { + a := &Authenticator{secret: []byte("test-secret-key-at-least-16"), domain: ".parodia.dev"} + rec := httptest.NewRecorder() + a.setCookie(rec, sessionCookie, "v", time.Minute) + a.setCookie(rec, oauthCookie, "v", time.Minute) + + got := map[string]string{} + for _, c := range rec.Result().Cookies() { + got[c.Name] = c.Domain + } + if got[sessionCookie] != "parodia.dev" { + t.Fatalf("session cookie domain = %q, want it shared across parodia.dev", got[sessionCookie]) + } + if got[oauthCookie] != "" { + t.Fatalf("oauth cookie must stay host-only, got domain %q", got[oauthCookie]) + } +}