Compare commits

...

5 Commits

Author SHA1 Message Date
Yang Luo
408a4396ed Delete object/dcr_test.go 2026-02-15 17:23:27 +08:00
copilot-swe-agent[bot]
a3a4e730b9 Address code review feedback - fix test logic and add safety checks
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 09:08:39 +00:00
copilot-swe-agent[bot]
59585432d0 Add DCR policy default and unit tests
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 09:07:19 +00:00
copilot-swe-agent[bot]
b8209d5553 Add OAuth 2.0 Dynamic Client Registration (RFC 7591) support
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 09:06:07 +00:00
copilot-swe-agent[bot]
9760404e8a Initial plan 2026-02-15 09:01:07 +00:00
7 changed files with 271 additions and 0 deletions

View File

@@ -59,6 +59,7 @@ p, *, *, GET, /api/get-qrcode, *, *
p, *, *, GET, /api/get-webhook-event, *, *
p, *, *, GET, /api/get-captcha-status, *, *
p, *, *, *, /api/login/oauth, *, *
p, *, *, POST, /api/oauth/register, *, *
p, *, *, GET, /api/get-application, *, *
p, *, *, GET, /api/get-organization-applications, *, *
p, *, *, GET, /api/get-user, *, *

74
controllers/dcr.go Normal file
View File

@@ -0,0 +1,74 @@
// 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 controllers
import (
"encoding/json"
"net/http"
"github.com/casdoor/casdoor/object"
)
// DynamicClientRegister
// @Title DynamicClientRegister
// @Tag OAuth API
// @Description Register a new OAuth 2.0 client dynamically (RFC 7591)
// @Param organization query string false "The organization name (defaults to built-in)"
// @Param body body object.DynamicClientRegistrationRequest true "Client registration request"
// @Success 201 {object} object.DynamicClientRegistrationResponse
// @Failure 400 {object} object.DcrError
// @router /api/oauth/register [post]
func (c *ApiController) DynamicClientRegister() {
var req object.DynamicClientRegistrationRequest
err := json.Unmarshal(c.Ctx.Input.RequestBody, &req)
if err != nil {
c.Ctx.Output.Status = http.StatusBadRequest
c.Data["json"] = object.DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "invalid request body: " + err.Error(),
}
c.ServeJSON()
return
}
// Get organization from query parameter or default to built-in
organization := c.Ctx.Input.Query("organization")
if organization == "" {
organization = "built-in"
}
// Register the client
response, dcrErr, err := object.RegisterDynamicClient(&req, organization)
if err != nil {
c.Ctx.Output.Status = http.StatusInternalServerError
c.Data["json"] = object.DcrError{
Error: "server_error",
ErrorDescription: err.Error(),
}
c.ServeJSON()
return
}
if dcrErr != nil {
c.Ctx.Output.Status = http.StatusBadRequest
c.Data["json"] = dcrErr
c.ServeJSON()
return
}
// Return 201 Created
c.Ctx.Output.Status = http.StatusCreated
c.Data["json"] = response
c.ServeJSON()
}

190
object/dcr.go Normal file
View File

@@ -0,0 +1,190 @@
// 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 (
"fmt"
"time"
"github.com/casdoor/casdoor/util"
)
// DynamicClientRegistrationRequest represents an RFC 7591 client registration request
type DynamicClientRegistrationRequest struct {
ClientName string `json:"client_name,omitempty"`
RedirectUris []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
Contacts []string `json:"contacts,omitempty"`
LogoUri string `json:"logo_uri,omitempty"`
ClientUri string `json:"client_uri,omitempty"`
PolicyUri string `json:"policy_uri,omitempty"`
TosUri string `json:"tos_uri,omitempty"`
Scope string `json:"scope,omitempty"`
}
// DynamicClientRegistrationResponse represents an RFC 7591 client registration response
type DynamicClientRegistrationResponse struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
ClientIdIssuedAt int64 `json:"client_id_issued_at,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
ClientName string `json:"client_name,omitempty"`
RedirectUris []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
Contacts []string `json:"contacts,omitempty"`
LogoUri string `json:"logo_uri,omitempty"`
ClientUri string `json:"client_uri,omitempty"`
PolicyUri string `json:"policy_uri,omitempty"`
TosUri string `json:"tos_uri,omitempty"`
Scope string `json:"scope,omitempty"`
RegistrationClientUri string `json:"registration_client_uri,omitempty"`
RegistrationAccessToken string `json:"registration_access_token,omitempty"`
}
// DcrError represents an RFC 7591 error response
type DcrError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// RegisterDynamicClient creates a new application based on DCR request
func RegisterDynamicClient(req *DynamicClientRegistrationRequest, organization string) (*DynamicClientRegistrationResponse, *DcrError, error) {
// Validate organization exists and has DCR enabled
org, err := GetOrganization(util.GetId("admin", organization))
if err != nil {
return nil, nil, err
}
if org == nil {
return nil, &DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "organization not found",
}, nil
}
// Check if DCR is enabled for this organization
if org.DcrPolicy == "" || org.DcrPolicy == "disabled" {
return nil, &DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "dynamic client registration is disabled for this organization",
}, nil
}
// Validate required fields
if len(req.RedirectUris) == 0 {
return nil, &DcrError{
Error: "invalid_redirect_uri",
ErrorDescription: "redirect_uris is required and must contain at least one URI",
}, nil
}
// Set defaults
if req.ClientName == "" {
clientIdPrefix := util.GenerateClientId()
if len(clientIdPrefix) > 8 {
clientIdPrefix = clientIdPrefix[:8]
}
req.ClientName = fmt.Sprintf("DCR Client %s", clientIdPrefix)
}
if len(req.GrantTypes) == 0 {
req.GrantTypes = []string{"authorization_code"}
}
if len(req.ResponseTypes) == 0 {
req.ResponseTypes = []string{"code"}
}
if req.TokenEndpointAuthMethod == "" {
req.TokenEndpointAuthMethod = "client_secret_basic"
}
if req.ApplicationType == "" {
req.ApplicationType = "web"
}
// Generate unique application name
randomName := util.GetRandomName()
appName := fmt.Sprintf("dcr_%s", randomName)
// Create Application object
// Note: DCR applications are created under "admin" owner by default
// This can be made configurable in future versions
clientId := util.GenerateClientId()
clientSecret := util.GenerateClientSecret()
createdTime := util.GetCurrentTime()
application := &Application{
Owner: "admin",
Name: appName,
Organization: organization,
CreatedTime: createdTime,
DisplayName: req.ClientName,
Logo: req.LogoUri,
HomepageUrl: req.ClientUri,
ClientId: clientId,
ClientSecret: clientSecret,
RedirectUris: req.RedirectUris,
GrantTypes: req.GrantTypes,
EnablePassword: false,
EnableSignUp: false,
DisableSignin: false,
EnableSigninSession: false,
EnableCodeSignin: true,
EnableAutoSignin: false,
TokenFormat: "JWT",
ExpireInHours: 168,
RefreshExpireInHours: 168,
CookieExpireInHours: 720,
FormOffset: 2,
Tags: []string{"dcr"},
TermsOfUse: req.TosUri,
}
// Add the application
affected, err := AddApplication(application)
if err != nil {
return nil, nil, err
}
if !affected {
return nil, &DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "failed to create client application",
}, nil
}
// Build response
response := &DynamicClientRegistrationResponse{
ClientId: clientId,
ClientSecret: clientSecret,
ClientIdIssuedAt: time.Now().Unix(),
ClientSecretExpiresAt: 0, // Never expires
ClientName: req.ClientName,
RedirectUris: req.RedirectUris,
GrantTypes: req.GrantTypes,
ResponseTypes: req.ResponseTypes,
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
ApplicationType: req.ApplicationType,
Contacts: req.Contacts,
LogoUri: req.LogoUri,
ClientUri: req.ClientUri,
PolicyUri: req.PolicyUri,
TosUri: req.TosUri,
Scope: req.Scope,
}
return response, nil, nil
}

View File

@@ -132,6 +132,7 @@ func initBuiltInOrganization() bool {
IsProfilePublic: false,
UseEmailAsUsername: false,
EnableTour: true,
DcrPolicy: "open",
}
_, err = AddOrganization(organization)
if err != nil {

View File

@@ -92,6 +92,8 @@ type Organization struct {
AccountMenu string `xorm:"varchar(20)" json:"accountMenu"`
AccountItems []*AccountItem `xorm:"mediumtext" json:"accountItems"`
DcrPolicy string `xorm:"varchar(100)" json:"dcrPolicy"`
OrgBalance float64 `json:"orgBalance"`
UserBalance float64 `json:"userBalance"`
BalanceCredit float64 `json:"balanceCredit"`

View File

@@ -32,6 +32,7 @@ type OidcDiscovery struct {
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
JwksUri string `json:"jwks_uri"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
ResponseTypesSupported []string `json:"response_types_supported"`
@@ -135,6 +136,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
TokenEndpoint: fmt.Sprintf("%s/api/login/oauth/access_token", originBackend),
UserinfoEndpoint: fmt.Sprintf("%s/api/userinfo", originBackend),
DeviceAuthorizationEndpoint: fmt.Sprintf("%s/api/device-auth", originBackend),
RegistrationEndpoint: fmt.Sprintf("%s/api/oauth/register", originBackend),
JwksUri: jwksUri,
IntrospectionEndpoint: fmt.Sprintf("%s/api/login/oauth/introspect", originBackend),
ResponseTypesSupported: []string{"code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none"},

View File

@@ -298,6 +298,7 @@ func InitAPI() {
web.Router("/api/login/oauth/access_token", &controllers.ApiController{}, "POST:GetOAuthToken")
web.Router("/api/login/oauth/refresh_token", &controllers.ApiController{}, "POST:RefreshToken")
web.Router("/api/login/oauth/introspect", &controllers.ApiController{}, "POST:IntrospectToken")
web.Router("/api/oauth/register", &controllers.ApiController{}, "POST:DynamicClientRegister")
web.Router("/api/get-records", &controllers.ApiController{}, "GET:GetRecords")
web.Router("/api/get-records-filter", &controllers.ApiController{}, "POST:GetRecordsByFilter")