Files
coder/coderd/oauth2provider/metadata_test.go
T
Bobby Ho 6348c83142 feat: register public clients without a secret (#28046)
Third in the stack, split out of #27873. This is where dynamic client
registration first produces a public client.

- A registration requesting token_endpoint_auth_method `none` issues no
secret and persists the client_type derived in #28043. The response
omits client_secret entirely, per RFC 7591 §3.2.1.
- Discovery still advertises only the two secret-based methods. `none`
is withheld until the token endpoint honors it, so a conforming client
is not told to attempt an exchange that would be rejected.
- The app and its secret are written in one transaction. They were two
independent inserts, so a failed second one left a committed app that
could never authenticate while still holding a registration access
token. Pre-existing, but a public client's missing secret row makes the
orphaned case unspottable by inspection.
- registration_client_uri uses url.JoinPath, fixing a trailing-slash
access URL producing //oauth2/clients/{id}.
- Docs state both limits above, so the page does not describe a flow
that returns 400 until #28047 lands.

A client registered here cannot obtain a token yet; #28047 adds that.
Dynamic client registration is off by default, so none of this is
user-visible until then.

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
2026-08-20 12:49:47 -07:00

146 lines
5.3 KiB
Go

package oauth2provider_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/oauth2provider"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
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()
// Use a plain HTTP client since this endpoint doesn't require authentication.
// Add a short readiness wait to avoid rare races with server startup.
endpoint := serverURL.ResolveReference(&url.URL{Path: "/.well-known/oauth-authorization-server"}).String()
var metadata codersdk.OAuth2AuthorizationServerMetadata
testutil.RequireEventuallyResponseOK(ctx, t, endpoint, &metadata)
// Verify the metadata
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
require.Contains(t, metadata.ResponseTypesSupported, codersdk.OAuth2ProviderResponseTypeCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, codersdk.OAuth2PKCECodeChallengeMethodS256)
// Pins the exact advertised set, not just that it contains something
// expected: a hardcoded list that dropped an accepted method or kept an
// unhonored one ("none": the token endpoint doesn't accept it yet) would
// still pass a Contains-only check.
require.ElementsMatch(t, codersdk.AdvertisedOAuth2TokenEndpointAuthMethods(), metadata.TokenEndpointAuthMethodsSupported)
// Supported scopes are published from the curated catalog
require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported)
}
// TestGetAuthorizationServerMetadata_DCREnabled is a focused unit test on
// the discovery handler itself, bypassing the full coderdtest HTTP server.
// It verifies the dynamic-client-registration-enabled gate: registration_endpoint
// is advertised once an admin explicitly enables DCR, omitted when explicitly
// disabled, and omitted by default when the setting has never been
// configured.
func TestGetAuthorizationServerMetadata_DCREnabled(t *testing.T) {
t.Parallel()
accessURL, err := url.Parse("https://oauth2-metadata-dcr-test.example.com")
require.NoError(t, err)
tests := []struct {
name string
// configureDCR is nil for "never configured".
configureDCR *bool
wantRegistrationEndpoint bool
}{
{
name: "EnabledAdvertisesRegistrationEndpoint",
configureDCR: ptr.Ref(true),
wantRegistrationEndpoint: true,
},
{
name: "DisabledOmitsRegistrationEndpoint",
configureDCR: ptr.Ref(false),
wantRegistrationEndpoint: false,
},
{
name: "NeverConfiguredDefaultsToOmitted",
configureDCR: nil,
wantRegistrationEndpoint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, _ := dbtestutil.NewDB(t)
if tt.configureDCR != nil {
err := db.UpsertOAuth2DCREnabled(ctx, *tt.configureDCR)
require.NoError(t, err)
}
handler := oauth2provider.GetAuthorizationServerMetadata(db, accessURL)
r := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil).WithContext(ctx)
rw := httptest.NewRecorder()
handler.ServeHTTP(rw, r)
require.Equal(t, http.StatusOK, rw.Code)
var metadata codersdk.OAuth2AuthorizationServerMetadata
require.NoError(t, json.Unmarshal(rw.Body.Bytes(), &metadata))
if tt.wantRegistrationEndpoint {
require.NotEmpty(t, metadata.RegistrationEndpoint)
} else {
require.Empty(t, metadata.RegistrationEndpoint)
}
})
}
}
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.
// Add a short readiness wait to avoid rare races with server startup.
endpoint := serverURL.ResolveReference(&url.URL{Path: "/.well-known/oauth-protected-resource"}).String()
var metadata codersdk.OAuth2ProtectedResourceMetadata
testutil.RequireEventuallyResponseOK(ctx, t, endpoint, &metadata)
// 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])
// RFC 6750 bearer tokens are now supported as fallback methods
require.Contains(t, metadata.BearerMethodsSupported, "header")
require.Contains(t, metadata.BearerMethodsSupported, "query")
// Supported scopes are published from the curated catalog
require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported)
}