Files
sessiongurad/internal/auth/access.go
jbergner f369ea5f52
All checks were successful
release-tag / release-image (push) Successful in 2m1s
release-main / release-images (push) Successful in 4m14s
Major Bugfix
2026-08-24 22:19:20 +02:00

509 lines
15 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/example/sessionguard/internal/model"
"golang.org/x/oauth2"
)
const backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
type AccessSessionStore interface {
PutAuthSession(model.AuthSession) error
GetAuthSession(hash string) (model.AuthSession, bool)
DeleteAuthSession(hash string) error
RevokeAuthSessions(sid, sub string) (int, error)
CleanupAuthSessions(time.Time) error
}
type accessPending struct {
Nonce string
CodeVerifier string
ReturnURL string
Exp time.Time
}
type AccessManager struct {
cfg model.AccessAuthConfig
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
logoutVer *oidc.IDTokenVerifier
oauth oauth2.Config
sessions AccessSessionStore
endSession string
mu sync.Mutex
pending map[string]accessPending
logoutSeen map[string]time.Time
}
func NewAccess(ctx context.Context, cfg model.AccessAuthConfig, sessions AccessSessionStore) (*AccessManager, error) {
if !cfg.Enabled {
return nil, nil
}
if sessions == nil {
return nil, errors.New("access auth session store is required")
}
p, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/"))
if err != nil {
return nil, err
}
var discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
_ = p.Claims(&discovery)
return &AccessManager{
cfg: cfg,
provider: p,
verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
// Back-channel logout tokens are not ID tokens and may omit exp. We
// still verify issuer, audience and signature, then validate the
// logout-specific claims below.
logoutVer: p.Verifier(&oidc.Config{ClientID: cfg.ClientID, SkipExpiryCheck: true}),
oauth: oauth2.Config{
ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret,
Endpoint: p.Endpoint(), RedirectURL: cfg.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
},
sessions: sessions,
endSession: discovery.EndSessionEndpoint,
pending: map[string]accessPending{},
logoutSeen: map[string]time.Time{},
}, nil
}
func (m *AccessManager) Register(mux *http.ServeMux) {
if m == nil {
return
}
mux.HandleFunc("/auth/verify", m.Verify)
mux.HandleFunc("GET /auth/login", m.Login)
mux.HandleFunc("GET /auth/oidc/callback", m.Callback)
mux.HandleFunc("GET /auth/logout", m.Logout)
mux.HandleFunc("POST /auth/logout", m.Logout)
mux.HandleFunc("POST /auth/backchannel-logout", m.BackchannelLogout)
mux.HandleFunc("GET /auth/status", m.Status)
}
func (m *AccessManager) Login(w http.ResponseWriter, r *http.Request) {
_ = m.sessions.CleanupAuthSessions(time.Now().UTC())
target := m.validReturnURL(r.URL.Query().Get("return"))
state, nonce := randomAccessToken(24), randomAccessToken(24)
verifier := oauth2.GenerateVerifier()
m.mu.Lock()
m.prunePendingLocked(time.Now())
m.pending[state] = accessPending{Nonce: nonce, CodeVerifier: verifier, ReturnURL: target, Exp: time.Now().Add(5 * time.Minute)}
m.mu.Unlock()
// One state cookie per login flow avoids the common multi-tab race where a
// second login overwrites the first flow's single state cookie.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName(state), Value: state, Path: m.externalCallbackPath(),
HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
http.Redirect(w, r, m.oauth.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier)), http.StatusFound)
}
func (m *AccessManager) Callback(w http.ResponseWriter, r *http.Request) {
if e := r.URL.Query().Get("error"); e != "" {
http.Error(w, "OIDC: "+e, http.StatusUnauthorized)
return
}
state := strings.TrimSpace(r.URL.Query().Get("state"))
if state == "" {
http.Error(w, "missing OIDC state", http.StatusUnauthorized)
return
}
cookieName := stateCookieName(state)
c, err := r.Cookie(cookieName)
if err != nil || c.Value != state {
http.Error(w, "OIDC state is not bound to this browser", http.StatusUnauthorized)
return
}
clearCookie(w, cookieName, "", m.externalCallbackPath(), m.cfg.SecureCookie)
m.mu.Lock()
p, ok := m.pending[state]
delete(m.pending, state)
m.mu.Unlock()
if !ok || time.Now().After(p.Exp) {
http.Error(w, "invalid or expired OIDC state", http.StatusUnauthorized)
return
}
tok, err := m.oauth.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(p.CodeVerifier))
if err != nil {
http.Error(w, "OIDC token exchange failed", http.StatusUnauthorized)
return
}
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok || strings.TrimSpace(rawIDToken) == "" {
http.Error(w, "missing id_token", http.StatusUnauthorized)
return
}
idToken, err := m.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Error(w, "invalid id_token", http.StatusUnauthorized)
return
}
if idToken.Nonce != p.Nonce {
http.Error(w, "invalid OIDC nonce", http.StatusUnauthorized)
return
}
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "invalid OIDC claims", http.StatusUnauthorized)
return
}
username := claimString(claims, m.cfg.UsernameClaim)
if username == "" {
// Pocket ID documents preferred_username and it is a safer Guacamole
// identity than display-name. Never invent a fallback identity.
username = claimString(claims, "preferred_username")
}
if username == "" {
http.Error(w, "OIDC token has no usable username claim", http.StatusForbidden)
return
}
groups := claimStrings(claims, "groups")
if !allowedGroups(groups, m.cfg.AllowedGroups) {
http.Error(w, "user is not in an allowed access group", http.StatusForbidden)
return
}
browserToken := randomAccessToken(32)
now := time.Now().UTC()
sess := model.AuthSession{
ID: randomAccessToken(12), TokenHash: hashAccessToken(browserToken), Subject: idToken.Subject,
SID: claimString(claims, "sid"), Username: username,
Email: claimString(claims, "email"), Name: claimString(claims, "name"),
Groups: groups, IDToken: rawIDToken, CreatedAt: now,
ExpiresAt: now.Add(time.Duration(m.cfg.SessionHours) * time.Hour),
}
if err := m.sessions.PutAuthSession(sess); err != nil {
http.Error(w, "could not create access session", http.StatusInternalServerError)
return
}
m.setSessionCookie(w, browserToken, int(time.Until(sess.ExpiresAt).Seconds()))
http.Redirect(w, r, p.ReturnURL, http.StatusFound)
}
func (m *AccessManager) Verify(w http.ResponseWriter, r *http.Request) {
sess, ok := m.sessionFromRequest(r)
if !ok {
target := m.forwardedTarget(r)
http.Redirect(w, r, m.loginURL(target), http.StatusFound)
return
}
// These are the only identity headers Traefik should copy to Guacamole.
// authResponseHeaders replaces conflicting client-provided values.
w.Header().Set("X-Guacamole-User", sess.Username)
w.Header().Set("X-SessionGuard-User", sess.Username)
if sess.Email != "" {
w.Header().Set("X-SessionGuard-Email", sess.Email)
}
if len(sess.Groups) > 0 {
w.Header().Set("X-SessionGuard-Groups", strings.Join(sess.Groups, ","))
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
}
func (m *AccessManager) Status(w http.ResponseWriter, r *http.Request) {
sess, ok := m.sessionFromRequest(r)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if !ok {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"authenticated":false}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"authenticated": true, "username": sess.Username, "email": sess.Email,
"groups": sess.Groups, "expires_at": sess.ExpiresAt,
})
}
func (m *AccessManager) Logout(w http.ResponseWriter, r *http.Request) {
sess, _ := m.sessionFromRequest(r)
if c, err := r.Cookie(m.cfg.CookieName); err == nil {
_ = m.sessions.DeleteAuthSession(hashAccessToken(c.Value))
}
clearCookie(w, m.cfg.CookieName, m.cfg.CookieDomain, "/", m.cfg.SecureCookie)
target := strings.TrimSpace(m.cfg.LogoutRedirectURL)
if target == "" {
target = m.validReturnURL(r.URL.Query().Get("return"))
}
if m.endSession == "" {
http.Redirect(w, r, target, http.StatusFound)
return
}
u, err := url.Parse(m.endSession)
if err != nil {
http.Redirect(w, r, target, http.StatusFound)
return
}
q := u.Query()
q.Set("client_id", m.cfg.ClientID)
if sess.IDToken != "" {
q.Set("id_token_hint", sess.IDToken)
}
if target != "" {
q.Set("post_logout_redirect_uri", target)
}
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}
func (m *AccessManager) BackchannelLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
raw := strings.TrimSpace(r.Form.Get("logout_token"))
if raw == "" {
http.Error(w, "missing logout_token", http.StatusBadRequest)
return
}
tok, err := m.logoutVer.Verify(r.Context(), raw)
if err != nil {
http.Error(w, "invalid logout_token", http.StatusBadRequest)
return
}
var claims struct {
SID string `json:"sid"`
Sub string `json:"sub"`
Nonce string `json:"nonce"`
JTI string `json:"jti"`
IAT int64 `json:"iat"`
Events map[string]json.RawMessage `json:"events"`
}
if err := tok.Claims(&claims); err != nil {
http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
return
}
if claims.Nonce != "" || claims.Events == nil {
http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
return
}
if _, ok := claims.Events[backchannelLogoutEvent]; !ok {
http.Error(w, "missing backchannel logout event", http.StatusBadRequest)
return
}
if claims.SID == "" && claims.Sub == "" {
http.Error(w, "logout_token has neither sid nor sub", http.StatusBadRequest)
return
}
if strings.TrimSpace(claims.JTI) == "" {
http.Error(w, "logout_token has no jti", http.StatusBadRequest)
return
}
if claims.IAT == 0 || time.Since(time.Unix(claims.IAT, 0)) > 10*time.Minute || time.Until(time.Unix(claims.IAT, 0)) > 5*time.Minute {
http.Error(w, "logout_token iat outside allowed window", http.StatusBadRequest)
return
}
if !m.acceptLogoutJTI(claims.JTI, time.Now()) {
http.Error(w, "logout_token replayed", http.StatusBadRequest)
return
}
if _, err := m.sessions.RevokeAuthSessions(claims.SID, claims.Sub); err != nil {
http.Error(w, "could not revoke access session", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (m *AccessManager) sessionFromRequest(r *http.Request) (model.AuthSession, bool) {
c, err := r.Cookie(m.cfg.CookieName)
if err != nil || strings.TrimSpace(c.Value) == "" {
return model.AuthSession{}, false
}
return m.sessions.GetAuthSession(hashAccessToken(c.Value))
}
func (m *AccessManager) setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: m.cfg.CookieName, Value: token, Path: "/", Domain: m.cfg.CookieDomain,
HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
MaxAge: maxAge,
})
}
func (m *AccessManager) forwardedTarget(r *http.Request) string {
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
uri := strings.TrimSpace(r.Header.Get("X-Forwarded-Uri"))
if proto == "" {
proto = "https"
}
if uri == "" {
uri = "/"
}
if host == "" {
return m.cfg.LogoutRedirectURL
}
return m.validReturnURL(proto + "://" + host + uri)
}
func (m *AccessManager) externalCallbackPath() string {
u, err := url.Parse(m.cfg.RedirectURL)
if err != nil || strings.TrimSpace(u.Path) == "" {
return "/auth/oidc/callback"
}
return u.Path
}
func (m *AccessManager) loginURL(target string) string {
u, _ := url.Parse(m.cfg.RedirectURL)
// Preserve an external Traefik prefix such as /_sessionguard. The Master
// itself sees /auth/* after StripPrefix, while the browser must be sent to
// the externally routable prefixed URL.
base := strings.TrimSuffix(u.Path, "/oidc/callback")
if base == u.Path {
base = strings.TrimSuffix(u.Path, "/")
}
u.Path = base + "/login"
u.RawQuery = url.Values{"return": []string{target}}.Encode()
return u.String()
}
func (m *AccessManager) validReturnURL(raw string) string {
fallback := strings.TrimSpace(m.cfg.LogoutRedirectURL)
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme != "https" || u.Hostname() == "" {
return fallback
}
host := strings.ToLower(u.Hostname())
for _, allowed := range m.cfg.AllowedHosts {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == host {
return u.String()
}
if strings.HasPrefix(allowed, "*.") && strings.HasSuffix(host, allowed[1:]) {
return u.String()
}
}
// If no allow-list was provided, constrain redirects to the configured
// cookie domain or, for host-only cookies, to logout_redirect_url. This is
// still closed against arbitrary open redirects.
if len(m.cfg.AllowedHosts) == 0 {
if m.cfg.CookieDomain != "" {
d := strings.TrimPrefix(strings.ToLower(m.cfg.CookieDomain), ".")
if host == d || strings.HasSuffix(host, "."+d) {
return u.String()
}
}
if f, err := url.Parse(fallback); err == nil && strings.EqualFold(f.Hostname(), host) {
return u.String()
}
}
return fallback
}
func (m *AccessManager) acceptLogoutJTI(jti string, now time.Time) bool {
m.mu.Lock()
defer m.mu.Unlock()
for k, exp := range m.logoutSeen {
if !now.Before(exp) {
delete(m.logoutSeen, k)
}
}
if _, exists := m.logoutSeen[jti]; exists {
return false
}
m.logoutSeen[jti] = now.Add(15 * time.Minute)
return true
}
func (m *AccessManager) prunePendingLocked(now time.Time) {
for k, p := range m.pending {
if now.After(p.Exp) {
delete(m.pending, k)
}
}
}
func allowedGroups(got, allowed []string) bool {
if len(allowed) == 0 {
return true
}
set := map[string]struct{}{}
for _, g := range got {
set[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
}
for _, g := range allowed {
if _, ok := set[strings.ToLower(strings.TrimSpace(g))]; ok {
return true
}
}
return false
}
func claimString(claims map[string]any, key string) string {
v, ok := claims[key]
if !ok {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
func claimStrings(claims map[string]any, key string) []string {
v, ok := claims[key]
if !ok {
return nil
}
switch x := v.(type) {
case []any:
out := make([]string, 0, len(x))
for _, e := range x {
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
case []string:
return append([]string(nil), x...)
case string:
if strings.TrimSpace(x) != "" {
return []string{strings.TrimSpace(x)}
}
}
return nil
}
func randomAccessToken(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func hashAccessToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func stateCookieName(state string) string {
if len(state) > 16 {
state = state[:16]
}
return "sg_access_state_" + state
}
func clearCookie(w http.ResponseWriter, name, domain, path string, secure bool) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Domain: domain, Path: path, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
}