Compare commits

...

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
b2b49dd6af refactor: address second round of review feedback
- Combine Custom and Flexible Custom cases in IdP factory
- Simplify PopulateThirdPartyLinks (remove unnecessary length check)
- Remove unnecessary ClearUserOAuthProperties for Flexible Custom unlink

Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-03-10 04:11:57 +00:00
copilot-swe-agent[bot]
bdef93f9af refactor: address code review feedback
- Handle upsert in AddThirdPartyLink to avoid duplicate key errors
- Move ThirdPartyLinks population to GetUser only (not on every getUser)
- Extract getUserByProvider and linkUserByProvider helpers to reduce duplication

Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-03-10 04:10:26 +00:00
copilot-swe-agent[bot]
e16a766fbf feat: add Flexible Custom provider type with third_party_link table
Create a new provider type "Flexible Custom" under both OAuth and SAML
categories. Add a new `third_party_link` table to store user-provider
associations instead of using fixed user struct fields.

Backend changes:
- New ThirdPartyLink model with CRUD operations
- Register table in ormer.go
- Add ThirdPartyLinks computed field to User struct
- Modify login flow to use third_party_link for Flexible Custom providers
- Modify link/unlink operations
- Add Flexible Custom to IdP factory

Frontend changes:
- Add Flexible Custom to provider type options (OAuth and SAML)
- Handle auth URL and logo
- Update OAuthWidget to display linked values from thirdPartyLinks
- Update unlink to pass provider name

Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-03-10 04:06:15 +00:00
copilot-swe-agent[bot]
edfd985830 Initial plan 2026-03-10 03:52:20 +00:00
10 changed files with 211 additions and 16 deletions

View File

@@ -505,6 +505,23 @@ func getExistUserByBindingRule(providerItem *object.ProviderItem, application *o
return user, nil
}
func getUserByProvider(organization string, provider *object.Provider, providerId string) (*object.User, error) {
if object.IsFlexibleCustomProvider(provider.Type) {
return object.GetUserByThirdPartyLink(organization, provider.Name, providerId)
}
if provider.Category == "SAML" {
return object.GetUserByFields(organization, providerId)
}
return object.GetUserByField(organization, provider.Type, providerId)
}
func linkUserByProvider(user *object.User, provider *object.Provider, providerId string) (bool, error) {
if object.IsFlexibleCustomProvider(provider.Type) {
return object.LinkFlexibleCustomAccount(user, provider.Name, providerId)
}
return object.LinkUserAccount(user, provider.Type, providerId)
}
// Login ...
// @Title Login
// @Tag Login API
@@ -863,15 +880,15 @@ func (c *ApiController) Login() {
if authForm.Method == "signup" {
user := &object.User{}
if provider.Category == "SAML" {
if provider.Category == "SAML" && !object.IsFlexibleCustomProvider(provider.Type) {
// The userInfo.Id is the NameID in SAML response, it could be name / email / phone
user, err = object.GetUserByFields(application.Organization, userInfo.Id)
if err != nil {
c.ResponseError(err.Error())
return
}
} else if provider.Category == "OAuth" || provider.Category == "Web3" {
user, err = object.GetUserByField(application.Organization, provider.Type, userInfo.Id)
} else if provider.Category == "OAuth" || provider.Category == "Web3" || object.IsFlexibleCustomProvider(provider.Type) {
user, err = getUserByProvider(application.Organization, provider, userInfo.Id)
if err != nil {
c.ResponseError(err.Error())
return
@@ -1034,7 +1051,7 @@ func (c *ApiController) Login() {
return
}
_, err = object.LinkUserAccount(user, provider.Type, userInfo.Id)
_, err = linkUserByProvider(user, provider, userInfo.Id)
if err != nil {
c.ResponseError(err.Error())
return
@@ -1057,7 +1074,7 @@ func (c *ApiController) Login() {
}
var oldUser *object.User
oldUser, err = object.GetUserByField(application.Organization, provider.Type, userInfo.Id)
oldUser, err = getUserByProvider(application.Organization, provider, userInfo.Id)
if err != nil {
c.ResponseError(err.Error())
return
@@ -1083,7 +1100,7 @@ func (c *ApiController) Login() {
}
var isLinked bool
isLinked, err = object.LinkUserAccount(user, provider.Type, userInfo.Id)
isLinked, err = linkUserByProvider(user, provider, userInfo.Id)
if err != nil {
c.ResponseError(err.Error())
return

View File

@@ -22,6 +22,7 @@ import (
type LinkForm struct {
ProviderType string `json:"providerType"`
ProviderName string `json:"providerName"`
User object.User `json:"user"`
}
@@ -87,6 +88,33 @@ func (c *ApiController) Unlink() {
// 1. the user is the global admin
// 2. the user is unlinking themselves and provider can be unlinked
if object.IsFlexibleCustomProvider(providerType) {
providerName := form.ProviderName
if providerName == "" {
c.ResponseError(c.T("link:Provider name is required for Flexible Custom providers"))
return
}
link, err := object.GetThirdPartyLink(unlinkedUser.Owner, unlinkedUser.Name, providerName)
if err != nil {
c.ResponseError(err.Error())
return
}
if link == nil {
c.ResponseError(c.T("link:Please link first"))
return
}
_, err = object.DeleteThirdPartyLink(unlinkedUser.Owner, unlinkedUser.Name, providerName)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk()
return
}
value := object.GetUserField(&unlinkedUser, providerType)
if value == "" {

View File

@@ -104,7 +104,7 @@ func GetIdProvider(idpInfo *ProviderInfo, redirectUrl string) (IdProvider, error
return NewBaiduIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
case "Alipay":
return NewAlipayIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
case "Custom":
case "Custom", "Flexible Custom":
return NewCustomIdProvider(idpInfo, redirectUrl), nil
case "Infoflow":
if idpInfo.SubType == "Internal" {

View File

@@ -469,4 +469,9 @@ func (a *Ormer) createTable() {
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(ThirdPartyLink))
if err != nil {
panic(err)
}
}

102
object/third_party_link.go Normal file
View File

@@ -0,0 +1,102 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type ThirdPartyLink struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
UserName string `xorm:"varchar(100) notnull pk" json:"userName"`
ProviderName string `xorm:"varchar(100) notnull pk" json:"providerName"`
ProviderId string `xorm:"varchar(100)" json:"providerId"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
}
func IsFlexibleCustomProvider(providerType string) bool {
return providerType == "Flexible Custom"
}
func GetThirdPartyLinksByUser(owner string, userName string) ([]*ThirdPartyLink, error) {
links := []*ThirdPartyLink{}
err := ormer.Engine.Where("owner = ? AND user_name = ?", owner, userName).Find(&links)
if err != nil {
return nil, err
}
return links, nil
}
func GetThirdPartyLink(owner string, userName string, providerName string) (*ThirdPartyLink, error) {
link := ThirdPartyLink{Owner: owner, UserName: userName, ProviderName: providerName}
existed, err := ormer.Engine.Get(&link)
if err != nil {
return nil, err
}
if existed {
return &link, nil
}
return nil, nil
}
func GetUserByThirdPartyLink(owner string, providerName string, providerId string) (*User, error) {
if owner == "" || providerName == "" || providerId == "" {
return nil, nil
}
link := ThirdPartyLink{}
existed, err := ormer.Engine.Where("owner = ? AND provider_name = ? AND provider_id = ?", owner, providerName, providerId).Get(&link)
if err != nil {
return nil, err
}
if !existed {
return nil, nil
}
return getUser(link.Owner, link.UserName)
}
func AddThirdPartyLink(link *ThirdPartyLink) (bool, error) {
existingLink, err := GetThirdPartyLink(link.Owner, link.UserName, link.ProviderName)
if err != nil {
return false, err
}
if existingLink != nil {
existingLink.ProviderId = link.ProviderId
affected, err := ormer.Engine.ID(core.PK{link.Owner, link.UserName, link.ProviderName}).Cols("provider_id").Update(existingLink)
if err != nil {
return false, err
}
return affected != 0, nil
}
link.CreatedTime = util.GetCurrentTime()
affected, err := ormer.Engine.Insert(link)
if err != nil {
return false, err
}
return affected != 0, nil
}
func DeleteThirdPartyLink(owner string, userName string, providerName string) (bool, error) {
affected, err := ormer.Engine.Delete(&ThirdPartyLink{Owner: owner, UserName: userName, ProviderName: providerName})
if err != nil {
return false, err
}
return affected != 0, nil
}

View File

@@ -228,6 +228,8 @@ type User struct {
Ldap string `xorm:"ldap varchar(100)" json:"ldap"`
Properties map[string]string `json:"properties"`
ThirdPartyLinks []*ThirdPartyLink `xorm:"-" json:"thirdPartyLinks,omitempty"`
Roles []*Role `json:"roles"`
Permissions []*Permission `json:"permissions"`
Groups []string `xorm:"mediumtext" json:"groups"`
@@ -660,7 +662,17 @@ func GetUser(id string) (*User, error) {
if err != nil {
return nil, err
}
return getUser(owner, name)
user, err := getUser(owner, name)
if err != nil {
return nil, err
}
if user != nil {
err = user.PopulateThirdPartyLinks()
if err != nil {
return nil, err
}
}
return user, nil
}
func GetUserNoCheck(id string) (*User, error) {
@@ -1261,10 +1273,32 @@ func LinkUserAccount(user *User, field string, value string) (bool, error) {
return SetUserField(user, field, value)
}
func LinkFlexibleCustomAccount(user *User, providerName string, providerId string) (bool, error) {
if providerId == "" {
return DeleteThirdPartyLink(user.Owner, user.Name, providerName)
}
link := &ThirdPartyLink{
Owner: user.Owner,
UserName: user.Name,
ProviderName: providerName,
ProviderId: providerId,
}
return AddThirdPartyLink(link)
}
func (user *User) GetId() string {
return fmt.Sprintf("%s/%s", user.Owner, user.Name)
}
func (user *User) PopulateThirdPartyLinks() error {
links, err := GetThirdPartyLinksByUser(user.Owner, user.Name)
if err != nil {
return err
}
user.ThirdPartyLinks = links
return nil
}
func (user *User) GetFriendlyName() string {
if user.FirstName != "" && user.LastName != "" {
return fmt.Sprintf("%s, %s", user.FirstName, user.LastName)

View File

@@ -1175,11 +1175,11 @@ export function getClickable(text) {
}
export function getProviderLogoURL(provider) {
if (provider.type.startsWith("Custom") && provider.customLogo) {
if ((provider.type.startsWith("Custom") || provider.type === "Flexible Custom") && provider.customLogo) {
return provider.customLogo;
}
if (provider.category === "OAuth") {
const type = provider.type.startsWith("Custom") ? "Custom" : provider.type;
const type = (provider.type.startsWith("Custom") || provider.type === "Flexible Custom") ? "Custom" : provider.type;
return `${StaticBaseUrl}/img/social_${type.toLowerCase()}.png`;
} else {
const info = OtherProviderInfo[provider.category][provider.type];
@@ -1290,6 +1290,7 @@ export function getProviderTypeOptions(category) {
{id: "Custom8", name: "Custom8"},
{id: "Custom9", name: "Custom9"},
{id: "Custom10", name: "Custom10"},
{id: "Flexible Custom", name: "Flexible Custom"},
]
);
} else if (category === "Email") {
@@ -1347,6 +1348,7 @@ export function getProviderTypeOptions(category) {
{id: "Aliyun IDaaS", name: "Aliyun IDaaS"},
{id: "Keycloak", name: "Keycloak"},
{id: "Custom", name: "Custom"},
{id: "Flexible Custom", name: "Flexible Custom"},
]);
} else if (category === "Payment") {
return ([

View File

@@ -380,7 +380,7 @@ const authInfo = {
export function getProviderUrl(provider) {
if (provider.category === "OAuth") {
const type = provider.type.startsWith("Custom") ? "Custom" : provider.type;
const type = (provider.type.startsWith("Custom") || provider.type === "Flexible Custom") ? "Custom" : provider.type;
const endpoint = authInfo[type].endpoint;
const urlObj = new URL(endpoint);
@@ -432,7 +432,7 @@ export function getAuthUrl(application, provider, method, code) {
if (application === null || provider === null) {
return "";
}
const type = provider.type.startsWith("Custom") ? "Custom" : provider.type;
const type = (provider.type.startsWith("Custom") || provider.type === "Flexible Custom") ? "Custom" : provider.type;
let endpoint = authInfo[type].endpoint;
const redirectOrigin = application.forcedRedirectOrigin ? application.forcedRedirectOrigin : window.location.origin;
let redirectUri = `${redirectOrigin}/callback`;

View File

@@ -95,9 +95,10 @@ class OAuthWidget extends React.Component {
return user.properties[key];
}
unlinkUser(providerType, linkedValue) {
unlinkUser(providerType, linkedValue, providerName) {
const body = {
providerType: providerType,
providerName: providerName || "",
// should add the unlink user's info, cause the user may not be logged in, but a admin want to unlink the user.
user: this.props.user,
};
@@ -133,7 +134,13 @@ class OAuthWidget extends React.Component {
renderIdp(user, application, providerItem) {
const provider = providerItem.provider;
const linkedValue = user[provider.type.toLowerCase()];
let linkedValue;
if (provider.type === "Flexible Custom") {
const link = (user.thirdPartyLinks || []).find(l => l.providerName === provider.name);
linkedValue = link ? link.providerId : "";
} else {
linkedValue = user[provider.type.toLowerCase()];
}
const profileUrl = this.getProviderLink(user, provider);
const id = this.getUserProperty(user, provider.type, "id");
const username = this.getUserProperty(user, provider.type, "username");
@@ -217,7 +224,7 @@ class OAuthWidget extends React.Component {
)
)
) : (
<Button disabled={!providerItem.canUnlink && !Setting.isAdminUser(account)} style={{marginLeft: "20px", width: linkButtonWidth}} onClick={() => this.unlinkUser(provider.type, linkedValue)}>{i18next.t("user:Unlink")}</Button>
<Button disabled={!providerItem.canUnlink && !Setting.isAdminUser(account)} style={{marginLeft: "20px", width: linkButtonWidth}} onClick={() => this.unlinkUser(provider.type, linkedValue, provider.name)}>{i18next.t("user:Unlink")}</Button>
)
}
</Col>

View File

@@ -121,7 +121,7 @@ export function renderOAuthProviderFields(provider, updateProviderField, renderU
)
}
{
provider.type.startsWith("Custom") ? (
(provider.type.startsWith("Custom") || provider.type === "Flexible Custom") ? (
<React.Fragment>
<Col>
<Row style={{marginTop: "20px"}} >