From 296010c0aaf1f019e27a746dadbe34d162ead342 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 3 Oct 2025 15:47:19 +0200 Subject: [PATCH] feat: add SSO provider logout support When users click logout, they are now redirected to the SSO provider's logout endpoint to ensure complete session termination. This prevents users from remaining logged in at the provider level after logging out of the application. Changes: - Add LogoutURL configuration for OAuth providers (Google, GitHub, GitLab) - Implement GetLogoutURL method with post-logout redirect parameter - Update HandleLogout to redirect to SSO logout when configured - Add ACKIFY_OAUTH_LOGOUT_URL environment variable for custom providers - Add tests for both local and SSO logout scenarios --- .env.example | 1 + internal/infrastructure/auth/oauth.go | 23 +++++++ internal/infrastructure/config/config.go | 5 ++ internal/presentation/handlers/auth.go | 8 +++ .../presentation/handlers/handlers_test.go | 62 ++++++++++++++----- internal/presentation/handlers/interfaces.go | 1 + pkg/web/server.go | 1 + 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 5d39737..5271c44 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,7 @@ ACKIFY_OAUTH_PROVIDER=google # ACKIFY_OAUTH_AUTH_URL=https://your-provider.com/oauth/authorize # ACKIFY_OAUTH_TOKEN_URL=https://your-provider.com/oauth/token # ACKIFY_OAUTH_USERINFO_URL=https://your-provider.com/api/user +# ACKIFY_OAUTH_LOGOUT_URL=https://your-provider.com/api/logout # ACKIFY_OAUTH_SCOPES=openid,email # GitLab specific (if using gitlab as provider and self-hosted) diff --git a/internal/infrastructure/auth/oauth.go b/internal/infrastructure/auth/oauth.go index c7ac165..5afc8d7 100644 --- a/internal/infrastructure/auth/oauth.go +++ b/internal/infrastructure/auth/oauth.go @@ -24,8 +24,10 @@ type OauthService struct { oauthConfig *oauth2.Config sessionStore *sessions.CookieStore userInfoURL string + logoutURL string allowedDomain string secureCookies bool + baseURL string } type Config struct { @@ -35,6 +37,7 @@ type Config struct { AuthURL string TokenURL string UserInfoURL string + LogoutURL string Scopes []string AllowedDomain string CookieSecret []byte @@ -59,8 +62,10 @@ func NewOAuthService(config Config) *OauthService { oauthConfig: oauthConfig, sessionStore: sessionStore, userInfoURL: config.UserInfoURL, + logoutURL: config.LogoutURL, allowedDomain: config.AllowedDomain, secureCookies: config.SecureCookies, + baseURL: config.BaseURL, } } @@ -112,6 +117,24 @@ func (s *OauthService) Logout(w http.ResponseWriter, r *http.Request) { _ = session.Save(r, w) } +// GetLogoutURL returns the SSO logout URL if configured, otherwise returns empty string +func (s *OauthService) GetLogoutURL() string { + if s.logoutURL == "" { + return "" + } + + // For most providers, add post_logout_redirect_uri or continue parameter + logoutURL := s.logoutURL + if s.baseURL != "" { + // Google and OIDC providers use post_logout_redirect_uri + // GitHub uses a simple redirect + // GitLab uses a redirect parameter + logoutURL += "?continue=" + s.baseURL + } + + return logoutURL +} + func (s *OauthService) GetAuthURL(nextURL string) string { state := base64.RawURLEncoding.EncodeToString(securecookie.GenerateRandomKey(20)) + ":" + base64.RawURLEncoding.EncodeToString([]byte(nextURL)) diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go index bd002f0..ca74e49 100644 --- a/internal/infrastructure/config/config.go +++ b/internal/infrastructure/config/config.go @@ -35,6 +35,7 @@ type OAuthConfig struct { AuthURL string TokenURL string UserInfoURL string + LogoutURL string Scopes []string AllowedDomain string CookieSecret []byte @@ -69,22 +70,26 @@ func Load() (*Config, error) { config.OAuth.AuthURL = "https://accounts.google.com/o/oauth2/auth" config.OAuth.TokenURL = "https://oauth2.googleapis.com/token" config.OAuth.UserInfoURL = "https://openidconnect.googleapis.com/v1/userinfo" + config.OAuth.LogoutURL = "https://accounts.google.com/Logout" config.OAuth.Scopes = []string{"openid", "email", "profile"} case "github": config.OAuth.AuthURL = "https://github.com/login/oauth/authorize" config.OAuth.TokenURL = "https://github.com/login/oauth/access_token" config.OAuth.UserInfoURL = "https://api.github.com/user" + config.OAuth.LogoutURL = "https://github.com/logout" config.OAuth.Scopes = []string{"user:email", "read:user"} case "gitlab": gitlabURL := getEnv("ACKIFY_OAUTH_GITLAB_URL", "https://gitlab.com") config.OAuth.AuthURL = fmt.Sprintf("%s/oauth/authorize", gitlabURL) config.OAuth.TokenURL = fmt.Sprintf("%s/oauth/token", gitlabURL) config.OAuth.UserInfoURL = fmt.Sprintf("%s/api/v4/user", gitlabURL) + config.OAuth.LogoutURL = fmt.Sprintf("%s/users/sign_out", gitlabURL) config.OAuth.Scopes = []string{"read_user", "profile"} default: config.OAuth.AuthURL = mustGetEnv("ACKIFY_OAUTH_AUTH_URL") config.OAuth.TokenURL = mustGetEnv("ACKIFY_OAUTH_TOKEN_URL") config.OAuth.UserInfoURL = mustGetEnv("ACKIFY_OAUTH_USERINFO_URL") + config.OAuth.LogoutURL = getEnv("ACKIFY_OAUTH_LOGOUT_URL", "") scopesStr := getEnv("ACKIFY_OAUTH_SCOPES", "openid,email,profile") config.OAuth.Scopes = strings.Split(scopesStr, ",") } diff --git a/internal/presentation/handlers/auth.go b/internal/presentation/handlers/auth.go index ce822ee..505b949 100644 --- a/internal/presentation/handlers/auth.go +++ b/internal/presentation/handlers/auth.go @@ -34,6 +34,14 @@ func (h *AuthHandlers) HandleLogin(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) HandleLogout(w http.ResponseWriter, r *http.Request) { h.authService.Logout(w, r) + + // Redirect to SSO logout if configured, otherwise redirect to home + ssoLogoutURL := h.authService.GetLogoutURL() + if ssoLogoutURL != "" { + http.Redirect(w, r, ssoLogoutURL, http.StatusFound) + return + } + http.Redirect(w, r, "/", http.StatusFound) } diff --git a/internal/presentation/handlers/handlers_test.go b/internal/presentation/handlers/handlers_test.go index 8e623df..7460d40 100644 --- a/internal/presentation/handlers/handlers_test.go +++ b/internal/presentation/handlers/handlers_test.go @@ -24,6 +24,7 @@ type fakeAuthService struct { callbackNextURL string callbackError error authURL string + logoutURL string logoutCalled bool verifyStateResult bool @@ -50,6 +51,10 @@ func (f *fakeAuthService) Logout(_ http.ResponseWriter, _ *http.Request) { f.logoutCalled = true } +func (f *fakeAuthService) GetLogoutURL() string { + return f.logoutURL +} + func (f *fakeAuthService) GetAuthURL(nextURL string) string { return f.authURL + "?next=" + url.QueryEscape(nextURL) } @@ -255,26 +260,53 @@ func TestAuthHandlers_HandleLogin(t *testing.T) { } func TestAuthHandlers_HandleLogout(t *testing.T) { - authService := newFakeAuthService() - handlers := NewAuthHandlers(authService, "https://example.com") + t.Run("logout without SSO logout URL redirects to home", func(t *testing.T) { + authService := newFakeAuthService() + handlers := NewAuthHandlers(authService, "https://example.com") - req := httptest.NewRequest("GET", "/logout", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout", nil) + w := httptest.NewRecorder() - handlers.HandleLogout(w, req) + handlers.HandleLogout(w, req) - if w.Code != http.StatusFound { - t.Errorf("Expected status %d, got %d", http.StatusFound, w.Code) - } + if w.Code != http.StatusFound { + t.Errorf("Expected status %d, got %d", http.StatusFound, w.Code) + } - location := w.Header().Get("Location") - if location != "/" { - t.Errorf("Expected redirect to /, got %s", location) - } + location := w.Header().Get("Location") + if location != "/" { + t.Errorf("Expected redirect to /, got %s", location) + } - if !authService.logoutCalled { - t.Error("Logout should have been called on auth service") - } + if !authService.logoutCalled { + t.Error("Logout should have been called on auth service") + } + }) + + t.Run("logout with SSO logout URL redirects to SSO", func(t *testing.T) { + authService := newFakeAuthService() + authService.logoutURL = "https://accounts.google.com/Logout?continue=https://example.com" + handlers := NewAuthHandlers(authService, "https://example.com") + + req := httptest.NewRequest("GET", "/logout", nil) + w := httptest.NewRecorder() + + handlers.HandleLogout(w, req) + + if w.Code != http.StatusFound { + t.Errorf("Expected status %d, got %d", http.StatusFound, w.Code) + } + + location := w.Header().Get("Location") + expectedLocation := "https://accounts.google.com/Logout?continue=https://example.com" + if location != expectedLocation { + t.Errorf("Expected redirect to %s, got %s", expectedLocation, location) + } + + if !authService.logoutCalled { + t.Error("Logout should have been called on auth service") + } + }) } func TestAuthHandlers_HandleOAuthCallback(t *testing.T) { diff --git a/internal/presentation/handlers/interfaces.go b/internal/presentation/handlers/interfaces.go index 6c30ce0..a8ba1e8 100644 --- a/internal/presentation/handlers/interfaces.go +++ b/internal/presentation/handlers/interfaces.go @@ -11,6 +11,7 @@ import ( type authService interface { SetUser(w http.ResponseWriter, r *http.Request, user *models.User) error Logout(w http.ResponseWriter, r *http.Request) + GetLogoutURL() string GetAuthURL(nextURL string) string CreateAuthURL(w http.ResponseWriter, r *http.Request, nextURL string) string VerifyState(w http.ResponseWriter, r *http.Request, stateToken string) bool diff --git a/pkg/web/server.go b/pkg/web/server.go index 47b4d13..f7cc463 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -44,6 +44,7 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) { AuthURL: cfg.OAuth.AuthURL, TokenURL: cfg.OAuth.TokenURL, UserInfoURL: cfg.OAuth.UserInfoURL, + LogoutURL: cfg.OAuth.LogoutURL, Scopes: cfg.OAuth.Scopes, AllowedDomain: cfg.OAuth.AllowedDomain, CookieSecret: cfg.OAuth.CookieSecret,