feat: add OAuth2 protected resource metadata endpoint for RFC 9728 (#18643)

# Add OAuth2 Protected Resource Metadata Endpoint

This PR implements the OAuth2 Protected Resource Metadata endpoint according to RFC 9728. The endpoint is available at `/.well-known/oauth-protected-resource` and provides information about Coder as an OAuth2 protected resource.

Key changes:
- Added a new endpoint at `/.well-known/oauth-protected-resource` that returns metadata about Coder as an OAuth2 protected resource
- Created a new `OAuth2ProtectedResourceMetadata` struct in the SDK
- Added tests to verify the endpoint functionality
- Updated API documentation to include the new endpoint

The implementation currently returns basic metadata including the resource identifier and authorization server URL. The `scopes_supported` field is empty until a scope system based on RBAC permissions is implemented. The `bearer_methods_supported` field is omitted as Coder uses custom authentication methods rather than standard RFC 6750 bearer tokens.

A TODO has been added to implement RFC 6750 bearer token support in the future.
This commit is contained in:
Thomas Kosiewski
2025-07-02 18:58:41 +02:00
committed by GitHub
parent 1b73b1a12f
commit 33bbf18a4b
10 changed files with 236 additions and 2 deletions
+46
View File
@@ -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": {
+42
View File
@@ -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": {
+2
View File
@@ -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
+2
View File
@@ -671,6 +671,8 @@ func APITokenFromRequest(r *http.Request) string {
return headerValue
}
// TODO(ThomasK33): Implement RFC 6750
return ""
}
+20
View File
@@ -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)
}
+45 -2
View File
@@ -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)
}