Compare commits

...

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ad2f67fee8 Address code review feedback: fix scheme detection and improve test safety
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 07:19:04 +00:00
copilot-swe-agent[bot]
d8f9de6a1c Fix getBackendUrl reference and add unit tests for OAuth PRM
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 07:17:12 +00:00
copilot-swe-agent[bot]
5c18bf9b99 Implement OAuth 2.0 Protected Resource Metadata (RFC 9728) endpoints and WWW-Authenticate header
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 07:13:06 +00:00
copilot-swe-agent[bot]
510f1278e4 Initial plan 2026-02-15 07:10:16 +00:00
5 changed files with 222 additions and 0 deletions

45
controllers/oauth_prm.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright 2021 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 (
"github.com/casdoor/casdoor/object"
)
// GetOauthProtectedResourceMetadata
// @Title GetOauthProtectedResourceMetadata
// @Tag OAuth 2.0 API
// @Description Get OAuth 2.0 Protected Resource Metadata (RFC 9728)
// @Success 200 {object} object.OauthProtectedResourceMetadata
// @router /.well-known/oauth-protected-resource [get]
func (c *RootController) GetOauthProtectedResourceMetadata() {
host := c.Ctx.Request.Host
c.Data["json"] = object.GetOauthProtectedResourceMetadata(host)
c.ServeJSON()
}
// GetOauthProtectedResourceMetadataByApplication
// @Title GetOauthProtectedResourceMetadataByApplication
// @Tag OAuth 2.0 API
// @Description Get OAuth 2.0 Protected Resource Metadata for specific application (RFC 9728)
// @Param application path string true "application name"
// @Success 200 {object} object.OauthProtectedResourceMetadata
// @router /.well-known/:application/oauth-protected-resource [get]
func (c *RootController) GetOauthProtectedResourceMetadataByApplication() {
application := c.Ctx.Input.Param(":application")
host := c.Ctx.Request.Host
c.Data["json"] = object.GetOauthProtectedResourceMetadataByApplication(host, application)
c.ServeJSON()
}

View File

@@ -0,0 +1,59 @@
// Copyright 2021 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"
)
// OauthProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata
type OauthProtectedResourceMetadata struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"`
ScopesSupported []string `json:"scopes_supported,omitempty"`
ResourceSigningAlg []string `json:"resource_signing_alg_values_supported,omitempty"`
ResourceDocumentation string `json:"resource_documentation,omitempty"`
}
// GetOauthProtectedResourceMetadata returns RFC 9728 Protected Resource Metadata for global discovery
func GetOauthProtectedResourceMetadata(host string) OauthProtectedResourceMetadata {
_, originBackend := getOriginFromHost(host)
return OauthProtectedResourceMetadata{
Resource: originBackend,
AuthorizationServers: []string{originBackend},
BearerMethodsSupported: []string{"header"},
ScopesSupported: []string{"openid", "profile", "email", "read", "write"},
ResourceSigningAlg: []string{"RS256"},
}
}
// GetOauthProtectedResourceMetadataByApplication returns RFC 9728 Protected Resource Metadata for application-specific discovery
func GetOauthProtectedResourceMetadataByApplication(host string, applicationName string) OauthProtectedResourceMetadata {
_, originBackend := getOriginFromHost(host)
// For application-specific discovery, the resource identifier includes the application name
resourceIdentifier := fmt.Sprintf("%s/.well-known/%s", originBackend, applicationName)
authServer := fmt.Sprintf("%s/.well-known/%s", originBackend, applicationName)
return OauthProtectedResourceMetadata{
Resource: resourceIdentifier,
AuthorizationServers: []string{authServer},
BearerMethodsSupported: []string{"header"},
ScopesSupported: []string{"openid", "profile", "email", "read", "write"},
ResourceSigningAlg: []string{"RS256"},
}
}

View File

@@ -0,0 +1,105 @@
// Copyright 2021 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 (
"strings"
"testing"
)
func TestGetOauthProtectedResourceMetadata(t *testing.T) {
// Test global discovery
host := "door.casdoor.com"
metadata := GetOauthProtectedResourceMetadata(host)
// Verify required fields are present
if metadata.Resource == "" {
t.Error("Resource field should not be empty")
}
if len(metadata.AuthorizationServers) == 0 {
t.Error("AuthorizationServers should not be empty")
}
// Verify resource and auth server match for global discovery
if metadata.Resource != metadata.AuthorizationServers[0] {
t.Errorf("For global discovery, Resource (%s) should match AuthorizationServers[0] (%s)",
metadata.Resource, metadata.AuthorizationServers[0])
}
// Verify it starts with https for proper domain
if len(metadata.Resource) < 8 || metadata.Resource[:8] != "https://" {
t.Errorf("Resource should start with https:// for domain, got: %s", metadata.Resource)
}
// Verify bearer methods supported
if len(metadata.BearerMethodsSupported) == 0 {
t.Error("BearerMethodsSupported should not be empty")
}
// Verify scopes supported
if len(metadata.ScopesSupported) == 0 {
t.Error("ScopesSupported should not be empty")
}
}
func TestGetOauthProtectedResourceMetadataByApplication(t *testing.T) {
// Test application-specific discovery
host := "door.casdoor.com"
appName := "my-app"
metadata := GetOauthProtectedResourceMetadataByApplication(host, appName)
// Verify required fields are present
if metadata.Resource == "" {
t.Error("Resource field should not be empty")
}
if len(metadata.AuthorizationServers) == 0 {
t.Error("AuthorizationServers should not be empty")
}
// Verify resource includes application name
expectedSuffix := "/.well-known/" + appName
if !strings.HasSuffix(metadata.Resource, expectedSuffix) {
t.Errorf("Resource should end with %s, got: %s", expectedSuffix, metadata.Resource)
}
// Verify auth server includes application name
if !strings.HasSuffix(metadata.AuthorizationServers[0], expectedSuffix) {
t.Errorf("AuthorizationServers[0] should end with %s, got: %s", expectedSuffix, metadata.AuthorizationServers[0])
}
// Verify resource and auth server match for application-specific discovery
if metadata.Resource != metadata.AuthorizationServers[0] {
t.Errorf("For application-specific discovery, Resource (%s) should match AuthorizationServers[0] (%s)",
metadata.Resource, metadata.AuthorizationServers[0])
}
}
func TestOauthProtectedResourceMetadataLocalhost(t *testing.T) {
// Test localhost (should use http://)
host := "localhost:8000"
metadata := GetOauthProtectedResourceMetadata(host)
// Verify it starts with http for localhost
if len(metadata.Resource) < 7 || metadata.Resource[:7] != "http://" {
t.Errorf("Resource should start with http:// for localhost, got: %s", metadata.Resource)
}
// Verify the host is included
if !strings.HasSuffix(metadata.Resource, host) {
t.Errorf("Resource should end with %s, got: %s", host, metadata.Resource)
}
}

View File

@@ -94,6 +94,17 @@ func denyMcpRequest(ctx *context.Context) {
Data: T(ctx, "auth:Unauthorized operation"),
})
// Add WWW-Authenticate header per MCP Authorization spec (RFC 9728)
// Use the same logic as getOriginFromHost to determine the scheme
host := ctx.Request.Host
scheme := "https"
if !strings.Contains(host, ".") {
// localhost:8000 or computer-name:80
scheme = "http"
}
resourceMetadataUrl := fmt.Sprintf("%s://%s/.well-known/oauth-protected-resource", scheme, host)
ctx.Output.Header("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"casdoor\", resource_metadata=\"%s\"", resourceMetadataUrl))
ctx.Output.SetStatus(http.StatusUnauthorized)
_ = ctx.Output.JSON(resp, true, false)
}

View File

@@ -324,6 +324,8 @@ func InitAPI() {
web.Router("/.well-known/:application/jwks", &controllers.RootController{}, "*:GetJwksByApplication")
web.Router("/.well-known/webfinger", &controllers.RootController{}, "GET:GetWebFinger")
web.Router("/.well-known/:application/webfinger", &controllers.RootController{}, "GET:GetWebFingerByApplication")
web.Router("/.well-known/oauth-protected-resource", &controllers.RootController{}, "GET:GetOauthProtectedResourceMetadata")
web.Router("/.well-known/:application/oauth-protected-resource", &controllers.RootController{}, "GET:GetOauthProtectedResourceMetadataByApplication")
web.Router("/cas/:organization/:application/serviceValidate", &controllers.RootController{}, "GET:CasServiceValidate")
web.Router("/cas/:organization/:application/proxyValidate", &controllers.RootController{}, "GET:CasProxyValidate")