mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-09 15:28:50 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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, ",")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user