From fded1483cc684588c03f5a6a26566cf40bf51b05 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 27 Jun 2025 07:17:00 +0200 Subject: [PATCH] feat(oauth2): implement RFC 9728 Protected Resource Metadata endpoint - Add OAuth2ProtectedResourceMetadata struct in codersdk/oauth2.go - Implement /.well-known/oauth-protected-resource endpoint handler - Register route in coderd.go for Protected Resource Metadata discovery - Add comprehensive test coverage in oauth2_metadata_test.go - Update OpenAPI documentation and generated API types - Correctly omit bearer_methods_supported field (Coder uses custom auth) - Support MCP OAuth2 compliance requirement for resource server metadata This implements RFC 9728 OAuth 2.0 Protected Resource Metadata to enable MCP clients to discover resource server capabilities and authorization servers. Change-Id: I089232ae755acf13eb0a7be46944c9eeaaafb75b Signed-off-by: Thomas Kosiewski --- coderd/apidoc/docs.go | 46 ++++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 42 +++++++++++++++++++++++++++++ coderd/coderd.go | 2 ++ coderd/httpmw/apikey.go | 2 ++ coderd/oauth2.go | 20 ++++++++++++++ coderd/oauth2_metadata_test.go | 32 ++++++++++++++++++++++ codersdk/oauth2.go | 8 ++++++ docs/reference/api/enterprise.md | 37 +++++++++++++++++++++++++ docs/reference/api/schemas.md | 26 ++++++++++++++++++ site/src/api/typesGenerated.ts | 8 ++++++ 10 files changed, 223 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 610388e16f4f4..2d5481ff1f4db 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -65,6 +65,26 @@ const docTemplate = `{ } } }, + "/.well-known/oauth-protected-resource": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "OAuth2 protected resource metadata.", + "operationId": "oauth2-protected-resource-metadata", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProtectedResourceMetadata" + } + } + } + } + }, "/appearance": { "get": { "security": [ @@ -13381,6 +13401,32 @@ const docTemplate = `{ } } }, + "codersdk.OAuth2ProtectedResourceMetadata": { + "type": "object", + "properties": { + "authorization_servers": { + "type": "array", + "items": { + "type": "string" + } + }, + "bearer_methods_supported": { + "type": "array", + "items": { + "type": "string" + } + }, + "resource": { + "type": "string" + }, + "scopes_supported": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.OAuth2ProviderApp": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index cc7ea271ab2bd..6329cb1901c44 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -49,6 +49,22 @@ } } }, + "/.well-known/oauth-protected-resource": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "OAuth2 protected resource metadata.", + "operationId": "oauth2-protected-resource-metadata", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProtectedResourceMetadata" + } + } + } + } + }, "/appearance": { "get": { "security": [ @@ -12057,6 +12073,32 @@ } } }, + "codersdk.OAuth2ProtectedResourceMetadata": { + "type": "object", + "properties": { + "authorization_servers": { + "type": "array", + "items": { + "type": "string" + } + }, + "bearer_methods_supported": { + "type": "array", + "items": { + "type": "string" + } + }, + "resource": { + "type": "string" + }, + "scopes_supported": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.OAuth2ProviderApp": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index e173bdfac4747..194671ed93fae 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -911,6 +911,8 @@ func New(options *Options) *API { // OAuth2 metadata endpoint for RFC 8414 discovery r.Get("/.well-known/oauth-authorization-server", api.oauth2AuthorizationServerMetadata) + // OAuth2 protected resource metadata endpoint for RFC 9728 discovery + r.Get("/.well-known/oauth-protected-resource", api.oauth2ProtectedResourceMetadata) // OAuth2 linking routes do not make sense under the /api/v2 path. These are // for an external application to use Coder as an OAuth2 provider, not for diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 71ee6ea10d08c..5c06abd0fb147 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -552,6 +552,8 @@ func APITokenFromRequest(r *http.Request) string { return headerValue } + // TODO(ThomasK33): Implement RFC 6750 + return "" } diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 6ddfb7f5efbf9..cc0b84501de21 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -417,3 +417,23 @@ func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *htt } httpapi.Write(ctx, rw, http.StatusOK, metadata) } + +// @Summary OAuth2 protected resource metadata. +// @ID oauth2-protected-resource-metadata +// @Produce json +// @Tags Enterprise +// @Success 200 {object} codersdk.OAuth2ProtectedResourceMetadata +// @Router /.well-known/oauth-protected-resource [get] +func (api *API) oauth2ProtectedResourceMetadata(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + metadata := codersdk.OAuth2ProtectedResourceMetadata{ + Resource: api.AccessURL.String(), + AuthorizationServers: []string{api.AccessURL.String()}, + // TODO: Implement scope system based on RBAC permissions + ScopesSupported: []string{}, + // Note: Coder uses custom authentication methods, not RFC 6750 bearer tokens + // TODO(ThomasK33): Implement RFC 6750 + // BearerMethodsSupported: []string{}, // Omitted - no standard bearer token support + } + httpapi.Write(ctx, rw, http.StatusOK, metadata) +} diff --git a/coderd/oauth2_metadata_test.go b/coderd/oauth2_metadata_test.go index b07208d4c9d58..163430bc30ef9 100644 --- a/coderd/oauth2_metadata_test.go +++ b/coderd/oauth2_metadata_test.go @@ -41,3 +41,35 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) { require.Contains(t, metadata.GrantTypesSupported, "refresh_token") require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256") } + +func TestOAuth2ProtectedResourceMetadata(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Get the protected resource metadata + resp, err := client.Request(ctx, http.MethodGet, "/.well-known/oauth-protected-resource", nil) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var metadata codersdk.OAuth2ProtectedResourceMetadata + err = json.NewDecoder(resp.Body).Decode(&metadata) + require.NoError(t, err) + + // Verify the metadata + require.NotEmpty(t, metadata.Resource) + require.NotEmpty(t, metadata.AuthorizationServers) + require.Len(t, metadata.AuthorizationServers, 1) + require.Equal(t, metadata.Resource, metadata.AuthorizationServers[0]) + // BearerMethodsSupported is omitted since Coder uses custom authentication methods + // Standard RFC 6750 bearer tokens are not supported + require.True(t, len(metadata.BearerMethodsSupported) == 0) + // ScopesSupported can be empty until scope system is implemented + // Empty slice is marshaled as empty array, but can be nil when unmarshaled + require.True(t, len(metadata.ScopesSupported) == 0) +} diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go index 84af80b211467..4c4407cbeaca1 100644 --- a/codersdk/oauth2.go +++ b/codersdk/oauth2.go @@ -244,3 +244,11 @@ type OAuth2AuthorizationServerMetadata struct { ScopesSupported []string `json:"scopes_supported,omitempty"` TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` } + +// OAuth2ProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata +type OAuth2ProtectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index cacdddfe37832..c885383a0fd35 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -46,6 +46,43 @@ curl -X GET http://coder-server:8080/api/v2/.well-known/oauth-authorization-serv |--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------------| | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2AuthorizationServerMetadata](schemas.md#codersdkoauth2authorizationservermetadata) | +## OAuth2 protected resource metadata + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/.well-known/oauth-protected-resource \ + -H 'Accept: application/json' +``` + +`GET /.well-known/oauth-protected-resource` + +### Example responses + +> 200 Response + +```json +{ + "authorization_servers": [ + "string" + ], + "bearer_methods_supported": [ + "string" + ], + "resource": "string", + "scopes_supported": [ + "string" + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2ProtectedResourceMetadata](schemas.md#codersdkoauth2protectedresourcemetadata) | + ## Get appearance ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1e565e53a710c..081cfb8571af3 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4284,6 +4284,32 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `device_flow` | boolean | false | | | | `enterprise_base_url` | string | false | | | +## codersdk.OAuth2ProtectedResourceMetadata + +```json +{ + "authorization_servers": [ + "string" + ], + "bearer_methods_supported": [ + "string" + ], + "resource": "string", + "scopes_supported": [ + "string" + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|-----------------|----------|--------------|-------------| +| `authorization_servers` | array of string | false | | | +| `bearer_methods_supported` | array of string | false | | | +| `resource` | string | false | | | +| `scopes_supported` | array of string | false | | | + ## codersdk.OAuth2ProviderApp ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1cd1e80253651..4ff007a175a4d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1481,6 +1481,14 @@ export interface OAuth2GithubConfig { readonly enterprise_base_url: string; } +// From codersdk/oauth2.go +export interface OAuth2ProtectedResourceMetadata { + readonly resource: string; + readonly authorization_servers: readonly string[]; + readonly scopes_supported?: readonly string[]; + readonly bearer_methods_supported?: readonly string[]; +} + // From codersdk/oauth2.go export interface OAuth2ProviderApp { readonly id: string;