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>
97 lines
3.2 KiB
Go
97 lines
3.2 KiB
Go
package api
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"geniusrun/backend/internal/auth"
|
|
)
|
|
|
|
// SessionConfig configures how the app-login session cookie is minted and
|
|
// validated. Secure should mirror config.Config.SessionSecure (true once
|
|
// the app is served over HTTPS).
|
|
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 {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
|
authURL, txn, err := s.Auth.BeginLogin()
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
http.SetCookie(w, cookie)
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
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
|
|
}
|
|
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
|
|
|
|
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
|
|
}
|
|
if !result.Authorized {
|
|
http.Redirect(w, r, "/?auth_error=forbidden", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
http.SetCookie(w, sessionCookie)
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
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(s.Session.PublicBaseURL+"/"), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
|
if !ok {
|
|
// Unreachable in practice -- RequireSession already 401s before this
|
|
// handler runs -- but fail closed rather than panic if that ever changes.
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email})
|
|
}
|