Files
zeekay 16a29a6ee1 util/settings, server/logout, util/db, util/rbac, util/dex: drop Argo wording
Settings.ArgoURLForRequest renamed to URLForRequest (and its one
caller in server/logout), matching the sibling RedirectURLForRequest
that had already lost the prefix.

util/db's TestRepoURLToSecretName exercised argoproj/ARGO-cd and
argoproj/argo_cd inputs; recomputed the FNV-32a secret names
(the function's own doc comment says the formula isn't stable
across releases, so there's no external contract to preserve) for
hanzoai/CD and hanzoai/hanzo_cd instead.

util/rbac's regex-match negative case and util/dex's "other
registered client" fixtures used argoproj/argo-cd/argo-workflow as
placeholder values with no bearing on the behavior under test;
swapped for generic placeholders.
2026-09-05 01:14:48 -07:00

141 lines
4.4 KiB
Go

package logout
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"
"github.com/hanzoai/cd/common"
httputil "github.com/hanzoai/cd/util/http"
jwtutil "github.com/hanzoai/cd/util/jwt"
"github.com/hanzoai/cd/util/session"
"github.com/hanzoai/cd/util/settings"
)
// NewHandler creates handler serving to do api/logout endpoint
func NewHandler(settingsMrg *settings.SettingsManager, sessionMgr *session.SessionManager, rootPath, baseHRef string) *Handler {
return &Handler{
settingsMgr: settingsMrg,
rootPath: rootPath,
baseHRef: baseHRef,
verifyToken: sessionMgr.VerifyToken,
revokeToken: sessionMgr.RevokeToken,
}
}
type Handler struct {
settingsMgr *settings.SettingsManager
rootPath string
verifyToken func(ctx context.Context, tokenString string) (jwt.Claims, string, error)
revokeToken func(ctx context.Context, id string, expiringAt time.Duration) error
baseHRef string
}
var (
tokenPattern = regexp.MustCompile(`{{token}}`)
logoutRedirectURLPattern = regexp.MustCompile(`{{logoutRedirectURL}}`)
)
func constructLogoutURL(logoutURL, token, logoutRedirectURL string) string {
constructedLogoutURL := tokenPattern.ReplaceAllString(logoutURL, token)
return logoutRedirectURLPattern.ReplaceAllString(constructedLogoutURL, logoutRedirectURL)
}
// ServeHTTP is the logout handler for Hanzo CD and constructs OIDC logout URL and redirects to it for OIDC issued sessions,
// and redirects user to '/login' for cd issued sessions
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var tokenString string
var oidcConfig *settings.OIDCConfig
cdSettings, err := h.settingsMgr.GetSettings()
if err != nil {
http.Error(w, "Failed to retrieve Hanzo CD settings: "+err.Error(), http.StatusInternalServerError)
return
}
cdURL, err := cdSettings.URLForRequest(r)
if err != nil {
log.Warnf("unable to find Hanzo CD URL from config: %v", err)
}
if cdURL == "" {
// golang does not provide any easy way to determine scheme of current request
// so redirecting ot http which will auto-redirect too https if necessary
host := strings.TrimRight(r.Host, "/")
cdURL = "http://" + host + "/" + strings.TrimRight(strings.TrimLeft(h.rootPath, "/"), "/")
}
logoutRedirectURL := strings.TrimRight(strings.TrimLeft(cdURL, "/"), "/")
cookies := r.Cookies()
tokenString, err = httputil.JoinCookies(common.AuthCookieName, cookies)
// Build message safely: only include err when non-nil
if err != nil {
http.Error(w, "Failed to retrieve Hanzo CD auth token: "+err.Error(), http.StatusBadRequest)
return
}
if tokenString == "" {
http.Error(w, "Failed to retrieve Hanzo CD auth token", http.StatusBadRequest)
return
}
for _, cookie := range cookies {
if !strings.HasPrefix(cookie.Name, common.AuthCookieName) {
continue
}
cdCookie := http.Cookie{
Name: cookie.Name,
Value: "",
}
cdCookie.Path = "/" + strings.TrimRight(strings.TrimLeft(h.baseHRef, "/"), "/")
w.Header().Add("Set-Cookie", cdCookie.String())
}
claims, _, err := h.verifyToken(r.Context(), tokenString)
if err != nil {
http.Redirect(w, r, logoutRedirectURL, http.StatusSeeOther)
return
}
mapClaims, err := jwtutil.MapClaims(claims)
if err != nil {
http.Redirect(w, r, logoutRedirectURL, http.StatusSeeOther)
return
}
issuer := jwtutil.StringField(mapClaims, "iss")
id := jwtutil.StringField(mapClaims, "jti")
// Workaround for Dex token, because does not have jti.
if id == "" {
id = jwtutil.StringField(mapClaims, "at_hash")
}
if exp, err := jwtutil.ExpirationTime(mapClaims); err == nil && id != "" {
ttl := time.Until(exp)
if ttl <= 0 {
// Token already expired; no need to persist revocation
log.Infof("token '%s' already expired, skipping revocation", id)
} else {
revokeCtx, cancel := context.WithTimeout(r.Context(), common.TokenRevocationTimeout)
defer cancel()
if err := h.revokeToken(revokeCtx, id, ttl); err != nil {
log.Warnf("failed to invalidate token '%s': %v", id, err)
}
}
}
if cdSettings.OIDCConfig() == nil || cdSettings.OIDCConfig().LogoutURL == "" || issuer == session.SessionManagerClaimsIssuer {
http.Redirect(w, r, logoutRedirectURL, http.StatusSeeOther)
} else {
oidcConfig = cdSettings.OIDCConfig()
logoutURL := constructLogoutURL(oidcConfig.LogoutURL, tokenString, logoutRedirectURL)
http.Redirect(w, r, logoutURL, http.StatusSeeOther)
}
}