Files
zeekayandHanzo Dev 1ab6e799c1 cd: the module is github.com/hanzoai/cd, where it is actually hosted
go.mod still declared `module github.com/hanzoai/deploy` after the repo was
renamed. A Go module path is not cosmetic — it is the resolvable location. So
`go get github.com/hanzoai/cd` could not work, and anything importing this
module had to spell a name that no longer exists at that address. The build log
of v3.6.0 is where it surfaced: the compiler was still emitting
github.com/hanzoai/deploy/cmd/cd/commands.

3541 import paths across 690 .go, 19 .proto, 4 .yaml, 3 .sh, 2 .mod, Procfile
and Makefile. Plus the 62 ghcr.io/hanzoai/deploy image references, moved to
ghcr.io/hanzoai/cd to match hanzo.yml's build target — a distinct string from
the module path, replaced separately so neither could clobber the other.

go mod tidy clean, CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ./... clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 21:16:24 -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
argoCDSettings, err := h.settingsMgr.GetSettings()
if err != nil {
http.Error(w, "Failed to retrieve argoCD settings: "+err.Error(), http.StatusInternalServerError)
return
}
argoURL, err := argoCDSettings.ArgoURLForRequest(r)
if err != nil {
log.Warnf("unable to find Hanzo CD URL from config: %v", err)
}
if argoURL == "" {
// 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, "/")
argoURL = "http://" + host + "/" + strings.TrimRight(strings.TrimLeft(h.rootPath, "/"), "/")
}
logoutRedirectURL := strings.TrimRight(strings.TrimLeft(argoURL, "/"), "/")
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 argoCDSettings.OIDCConfig() == nil || argoCDSettings.OIDCConfig().LogoutURL == "" || issuer == session.SessionManagerClaimsIssuer {
http.Redirect(w, r, logoutRedirectURL, http.StatusSeeOther)
} else {
oidcConfig = argoCDSettings.OIDCConfig()
logoutURL := constructLogoutURL(oidcConfig.LogoutURL, tokenString, logoutRedirectURL)
http.Redirect(w, r, logoutURL, http.StatusSeeOther)
}
}