Compare commits

...

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f741daf631 Add custom OAuth provider logout/end_session endpoint support
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-03-11 13:42:05 +00:00
copilot-swe-agent[bot]
0d686a78c1 Initial plan 2026-03-11 13:27:52 +00:00
5 changed files with 100 additions and 1 deletions

View File

@@ -373,6 +373,16 @@ func (c *ApiController) Logout() {
return
}
// Get the application and user BEFORE clearing the session so we can invoke
// the upstream provider's logout endpoint while the session is still active.
application := c.GetSessionApplication()
userObj, err := object.GetUser(user)
if err != nil {
util.LogWarning(c.Ctx, "Logout: failed to get user %s for provider logout: %v", user, err)
} else if userObj != nil && application != nil {
object.InvokeProviderLogout(userObj, application)
}
c.ClearUserSession()
c.ClearTokenSession()
@@ -381,7 +391,6 @@ func (c *ApiController) Logout() {
return
}
application := c.GetSessionApplication()
if application == nil || application.Name == "app-built-in" || application.HomepageUrl == "" {
c.ResponseOk(user)
return
@@ -417,6 +426,14 @@ func (c *ApiController) Logout() {
user = util.GetId(token.Organization, token.User)
}
// Invoke upstream provider logout before clearing the Casdoor session.
userObj, err := object.GetUser(user)
if err != nil {
util.LogWarning(c.Ctx, "Logout: failed to get user %s for provider logout: %v", user, err)
} else if userObj != nil {
object.InvokeProviderLogout(userObj, application)
}
c.ClearUserSession()
c.ClearTokenSession()

View File

@@ -17,9 +17,13 @@ package object
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web/context"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/idp"
@@ -47,6 +51,7 @@ type Provider struct {
CustomAuthUrl string `xorm:"varchar(200)" json:"customAuthUrl"`
CustomTokenUrl string `xorm:"varchar(200)" json:"customTokenUrl"`
CustomUserInfoUrl string `xorm:"varchar(200)" json:"customUserInfoUrl"`
CustomLogoutUrl string `xorm:"varchar(200)" json:"customLogoutUrl"`
CustomLogo string `xorm:"varchar(200)" json:"customLogo"`
Scopes string `xorm:"varchar(100)" json:"scopes"`
UserMapping map[string]string `xorm:"varchar(500)" json:"userMapping"`
@@ -610,3 +615,66 @@ func GetIdvProviderFromProvider(provider *Provider) idv.IdvProvider {
}
return idv.GetIdvProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.Endpoint)
}
// InvokeProviderLogout calls the external OAuth provider's logout endpoint for all Custom OAuth providers
// linked to the user in the given application. This is called during Casdoor logout to propagate the
// logout to upstream identity providers.
func InvokeProviderLogout(user *User, application *Application) {
if user == nil || application == nil {
return
}
for _, providerItem := range application.Providers {
provider := providerItem.Provider
if provider == nil {
continue
}
if provider.Category != "OAuth" {
continue
}
if !strings.HasPrefix(provider.Type, "Custom") {
continue
}
if provider.CustomLogoutUrl == "" {
continue
}
accessToken := GetUserOAuthAccessToken(user, provider.Type)
if accessToken == "" {
continue
}
refreshToken := GetUserOAuthRefreshToken(user, provider.Type)
go callProviderLogoutUrl(provider, accessToken, refreshToken)
}
}
// callProviderLogoutUrl makes an HTTP POST request to the provider's configured logout URL.
// It sends token, client_id, client_secret, and optionally refresh_token as form parameters.
// This is compatible with OAuth2 Token Revocation (RFC 7009) and OIDC back-channel logout.
func callProviderLogoutUrl(provider *Provider, accessToken, refreshToken string) {
params := url.Values{}
params.Set("token", accessToken)
if provider.ClientId != "" {
params.Set("client_id", provider.ClientId)
}
if provider.ClientSecret != "" {
params.Set("client_secret", provider.ClientSecret)
}
if refreshToken != "" {
params.Set("refresh_token", refreshToken)
}
resp, err := http.PostForm(provider.CustomLogoutUrl, params)
if err != nil {
logs.Warning("InvokeProviderLogout: failed to call logout URL %s for provider %s: %v", provider.CustomLogoutUrl, provider.Name, err)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
logs.Warning("InvokeProviderLogout: logout URL %s for provider %s returned status %d: %s", provider.CustomLogoutUrl, provider.Name, resp.StatusCode, string(body))
}
}

View File

@@ -1148,6 +1148,8 @@
"To address - Tooltip": "Email address of \"To\"",
"Token URL": "Token URL",
"Token URL - Tooltip": "Custom OAuth Token URL",
"Logout URL": "Logout URL",
"Logout URL - Tooltip": "Custom OAuth logout/end_session URL. When set, Casdoor will call this endpoint during logout to terminate the external provider session.",
"Use WeChat Media Platform in PC": "Use WeChat Media Platform in PC",
"Use WeChat Media Platform in PC - Tooltip": "Whether to allow scanning WeChat Media Platform QR code to login",
"Use WeChat Media Platform to login": "Use WeChat Media Platform to login",

View File

@@ -1148,6 +1148,8 @@
"To address - Tooltip": "邮件的收件人地址",
"Token URL": "Token链接",
"Token URL - Tooltip": "自定义OAuth的Token链接",
"Logout URL": "注销链接",
"Logout URL - Tooltip": "自定义OAuth的注销/结束会话链接。设置后Casdoor将在注销时调用此端点以终止外部提供商的会话。",
"Use WeChat Media Platform in PC": "在PC端使用微信公众平台",
"Use WeChat Media Platform in PC - Tooltip": "是否使用微信公众平台的二维码进行登录",
"Use WeChat Media Platform to login": "使用微信公众平台进行登录",

View File

@@ -144,6 +144,16 @@ export function renderOAuthProviderFields(provider, updateProviderField, renderU
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Logout URL"), i18next.t("provider:Logout URL - Tooltip"))}
</Col>
<Col span={22} >
<Input value={provider.customLogoutUrl} onChange={e => {
updateProviderField("customLogoutUrl", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Scope"), i18next.t("provider:Scope - Tooltip"))}