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)
}
+8
View File
@@ -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"`
}
+37
View File
@@ -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
+26
View File
@@ -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
+8
View File
@@ -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;