Skip to content

Commit d4b7b0c

Browse files
authored
Correctly construct resource_metadata per RFC 9728 Section 3.1 (#2203)
The resource_metadata parameter in WWW-Authenticate header must contain the full URL to the metadata endpoint, formed by inserting /.well-known/oauth-protected-resource between the host and path. Examples: - Resource: http://localhost:8080 Metadata: http://localhost:8080/.well-known/oauth-protected-resource - Resource: http://localhost:9090/mcp Metadata: http://localhost:9090/.well-known/oauth-protected-resource/mcp This enables clients to discover OAuth metadata for the protected resource. Fixes #2202
1 parent f638c69 commit d4b7b0c

File tree

2 files changed

+138
-2
lines changed

2 files changed

+138
-2
lines changed

pkg/auth/token.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,8 +772,24 @@ func (v *TokenValidator) buildWWWAuthenticate(includeError bool, errDescription
772772
}
773773

774774
// resource_metadata (RFC 9728)
775+
// Per RFC 9728 Section 3.1, the well-known URI is inserted between the host and path components
776+
// Example: https://resource.example.com/resource1 -> https://resource.example.com/.well-known/oauth-protected-resource/resource1
775777
if v.resourceURL != "" {
776-
parts = append(parts, fmt.Sprintf(`resource_metadata="%s"`, EscapeQuotes(v.resourceURL)))
778+
parsedURL, err := url.Parse(v.resourceURL)
779+
if err == nil {
780+
// Per RFC 9728 Section 3.1, remove any terminating slash from path
781+
path := parsedURL.Path
782+
if path == "/" {
783+
path = ""
784+
}
785+
786+
// Construct the metadata URL by inserting the well-known path between host and path
787+
metadataURL := fmt.Sprintf("%s://%s/.well-known/oauth-protected-resource%s",
788+
parsedURL.Scheme,
789+
parsedURL.Host,
790+
path)
791+
parts = append(parts, fmt.Sprintf(`resource_metadata="%s"`, EscapeQuotes(metadataURL)))
792+
}
777793
}
778794

779795
// error fields (RFC 6750 §3)

pkg/auth/token_test.go

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1394,7 +1394,7 @@ func TestBuildWWWAuthenticate_Format(t *testing.T) {
13941394
t.Parallel()
13951395
tv := &TokenValidator{
13961396
issuer: "https://issuer.example.com",
1397-
resourceURL: "https://resource.example.com/.well-known/oauth-protected-resource",
1397+
resourceURL: "https://resource.example.com",
13981398
}
13991399
got := tv.buildWWWAuthenticate(true, `failed to parse "token", reason`)
14001400
want := `Bearer realm="https://issuer.example.com", resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="failed to parse \"token\", reason"`
@@ -1403,6 +1403,126 @@ func TestBuildWWWAuthenticate_Format(t *testing.T) {
14031403
}
14041404
}
14051405

1406+
func TestBuildWWWAuthenticate_ResourceMetadata(t *testing.T) {
1407+
t.Parallel()
1408+
1409+
tests := []struct {
1410+
name string
1411+
issuer string
1412+
resourceURL string
1413+
includeError bool
1414+
errDescription string
1415+
expectedResourceMetadata string
1416+
}{
1417+
{
1418+
name: "resource URL without path",
1419+
issuer: "https://issuer.example.com",
1420+
resourceURL: "http://localhost:8080",
1421+
includeError: false,
1422+
expectedResourceMetadata: `resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"`,
1423+
},
1424+
{
1425+
name: "resource URL with trailing slash",
1426+
issuer: "https://issuer.example.com",
1427+
resourceURL: "http://localhost:8080/",
1428+
includeError: false,
1429+
expectedResourceMetadata: `resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"`,
1430+
},
1431+
{
1432+
name: "resource URL with path",
1433+
issuer: "https://issuer.example.com",
1434+
resourceURL: "http://localhost:9090/mcp",
1435+
includeError: false,
1436+
expectedResourceMetadata: `resource_metadata="http://localhost:9090/.well-known/oauth-protected-resource/mcp"`,
1437+
},
1438+
{
1439+
name: "resource URL with path and trailing slash",
1440+
issuer: "https://issuer.example.com",
1441+
resourceURL: "http://localhost:9090/mcp/",
1442+
includeError: false,
1443+
expectedResourceMetadata: `resource_metadata="http://localhost:9090/.well-known/oauth-protected-resource/mcp/"`,
1444+
},
1445+
{
1446+
name: "resource URL with nested path",
1447+
issuer: "https://issuer.example.com",
1448+
resourceURL: "https://api.example.com/v1/mcp",
1449+
includeError: false,
1450+
expectedResourceMetadata: `resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"`,
1451+
},
1452+
{
1453+
name: "resource URL with HTTPS",
1454+
issuer: "https://issuer.example.com",
1455+
resourceURL: "https://resource.example.com",
1456+
includeError: false,
1457+
expectedResourceMetadata: `resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource"`,
1458+
},
1459+
{
1460+
name: "empty resource URL",
1461+
issuer: "https://issuer.example.com",
1462+
resourceURL: "",
1463+
includeError: false,
1464+
expectedResourceMetadata: "",
1465+
},
1466+
{
1467+
name: "with error and description",
1468+
issuer: "https://issuer.example.com",
1469+
resourceURL: "http://localhost:8080",
1470+
includeError: true,
1471+
errDescription: "token expired",
1472+
expectedResourceMetadata: `resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"`,
1473+
},
1474+
}
1475+
1476+
for _, tt := range tests {
1477+
t.Run(tt.name, func(t *testing.T) {
1478+
t.Parallel()
1479+
1480+
tv := &TokenValidator{
1481+
issuer: tt.issuer,
1482+
resourceURL: tt.resourceURL,
1483+
}
1484+
1485+
got := tv.buildWWWAuthenticate(tt.includeError, tt.errDescription)
1486+
1487+
// Check that it starts with "Bearer "
1488+
if !strings.HasPrefix(got, "Bearer ") {
1489+
t.Errorf("Expected header to start with 'Bearer ', got: %s", got)
1490+
}
1491+
1492+
// Check realm is present
1493+
if tt.issuer != "" && !strings.Contains(got, fmt.Sprintf(`realm="%s"`, tt.issuer)) {
1494+
t.Errorf("Expected realm to be present in: %s", got)
1495+
}
1496+
1497+
// Check resource_metadata
1498+
if tt.expectedResourceMetadata != "" {
1499+
if !strings.Contains(got, tt.expectedResourceMetadata) {
1500+
t.Errorf("Expected resource_metadata:\n %s\nto be in:\n %s", tt.expectedResourceMetadata, got)
1501+
}
1502+
} else if tt.resourceURL == "" {
1503+
// If resource URL is empty, resource_metadata should not be present
1504+
if strings.Contains(got, "resource_metadata") {
1505+
t.Errorf("Expected no resource_metadata in: %s", got)
1506+
}
1507+
}
1508+
1509+
// Check error fields
1510+
if tt.includeError {
1511+
if !strings.Contains(got, `error="invalid_token"`) {
1512+
t.Errorf("Expected error field in: %s", got)
1513+
}
1514+
if tt.errDescription != "" && !strings.Contains(got, fmt.Sprintf(`error_description="%s"`, tt.errDescription)) {
1515+
t.Errorf("Expected error_description in: %s", got)
1516+
}
1517+
} else {
1518+
if strings.Contains(got, "error=") {
1519+
t.Errorf("Expected no error field in: %s", got)
1520+
}
1521+
}
1522+
})
1523+
}
1524+
}
1525+
14061526
func TestIntrospectGoogleToken(t *testing.T) {
14071527
t.Parallel()
14081528

0 commit comments

Comments
 (0)