diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3b966a4cfb..57f5d1640e 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 668fc952a0..e5c6d1025f 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 dbd9051688..07c345135a 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 655edaf59f..3884975e10 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 6ddfb7f5ef..cc0b84501d 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 b07208d4c9..9c3409db11 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 84af80b211..4c4407cbea 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 cacdddfe37..c885383a0f 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 00253364f9..2a5c9ed380 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 e47f95ff12..95152c4405 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;