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
This commit is contained in:
Benjamin
2025-10-03 15:47:19 +02:00
parent 2583482198
commit 296010c0aa
7 changed files with 86 additions and 15 deletions

View File

@@ -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)

View File

@@ -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))

View File

@@ -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, ",")
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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,