From 59b7a9d86465eecb1f17734371ab31ec1566293e 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 | 47 ++++++++++++++++++++++++++++++-- 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, 236 insertions(+), 2 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3b966a4cfb0d6..57f5d1640e182 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": [ @@ -13450,6 +13470,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 668fc952a01a2..e5c6d1025f20c 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": [ @@ -12116,6 +12132,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 dbd90516884b1..07c345135a5eb 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -914,6 +914,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 655edaf59f2ab..3884975e10c74 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -671,6 +671,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..9c3409db11946 100644 --- a/coderd/oauth2_metadata_test.go +++ b/coderd/oauth2_metadata_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -17,12 +18,17 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) + serverURL := client.URL ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - // Get the metadata - resp, err := client.Request(ctx, http.MethodGet, "/.well-known/oauth-authorization-server", nil) + // Use a plain HTTP client since this endpoint doesn't require authentication + endpoint := serverURL.ResolveReference(&url.URL{Path: "/.well-known/oauth-authorization-server"}).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -41,3 +47,40 @@ 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) + serverURL := client.URL + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Use a plain HTTP client since this endpoint doesn't require authentication + endpoint := serverURL.ResolveReference(&url.URL{Path: "/.well-known/oauth-protected-resource"}).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + 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 00253364f9219..2a5c9ed380441 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4287,6 +4287,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 e47f95ff129f4..95152c4405489 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1482,6 +1482,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;