Fix OIDC login-gate cross-file wiring bugs from whole-branch review

Four bugs slipped through per-task review since each task only saw its
own diff:

- Logout was a plain <a href> GET against a POST-only backend route, so
  it 405'd and never cleared the session cookie or hit Keycloak's
  end-session redirect. Now a <form method="post"> with a submit button
  styled to match the old link (still a real full-page navigation, not
  a fetch, so the Keycloak redirect chain still works).
- Login/logout used origin-relative paths, unreachable from the Vite
  dev server (:5173) against the backend (:8080) with no proxy
  configured. Both now build their URL from client.ts's now-exported
  BASE_URL.
- handleSessionCallback's four failure paths redirected to
  /?auth_error=failed with no logging, making a real OIDC failure
  undiagnosable in production. Added log.Printf on each failure site.
- handleSessionLogout passed a bare "/" to EndSessionURL; Keycloak
  requires post_logout_redirect_uri to be an absolute, registered URL.
  Added SessionConfig.PublicBaseURL, wired from cfg.PublicBaseURL in
  main.go, and used to build an absolute redirect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:03:02 +02:00
parent eeff1e9b40
commit 502e61e7b4
7 changed files with 39 additions and 17 deletions

View File

@@ -61,9 +61,10 @@ func main() {
}
server := api.NewServer(db, garminClient, syncSvc, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure,
Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure,
PublicBaseURL: cfg.PublicBaseURL,
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

View File

@@ -23,9 +23,10 @@ import (
func newCtx() context.Context { return context.Background() }
var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
Secure: false,
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
Secure: false,
PublicBaseURL: "https://geniusrun.example.com",
}
func newTestServer(t *testing.T) (*Server, *store.DB) {

View File

@@ -1,6 +1,7 @@
package api
import (
"log"
"net/http"
"time"
@@ -14,6 +15,12 @@ type SessionConfig struct {
Secret []byte
Duration time.Duration
Secure bool
// PublicBaseURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash), used to build an
// absolute post_logout_redirect_uri for the identity provider -- some
// providers, including Keycloak, require this to be an absolute URL
// matching one registered on the client, not a bare relative path.
PublicBaseURL string
}
type sessionMeResponse struct {
@@ -39,6 +46,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil {
log.Printf("session callback: missing txn cookie: %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
return
}
@@ -46,12 +54,14 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
return
}
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
return
}
@@ -71,7 +81,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL("/"), http.StatusFound)
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), http.StatusFound)
}
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {