Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions coderd/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,29 @@ func TestOAuth2ProviderAppSecrets(t *testing.T) {
_, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
require.Error(t, err)
})

t.Run("RejectsPublicClient", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

oauth2providertest.EnableDCR(t, client)
app := oauth2providertest.RegisterPublicClient(t, client, "app-secrets-public", "http://localhost:8080/callback")
appID, err := uuid.Parse(app.ClientID)
require.NoError(t, err)

// A public client authenticates with PKCE alone, so minting a secret
// would hand an operator a credential the token endpoint never
// checks, and one whose deletion looks like a kill switch but
// revokes nothing.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PostOAuth2ProviderAppSecret(ctx, appID)
require.Error(t, err)

//nolint:gocritic // OAuth2 app management requires owner permission.
secrets, err := client.OAuth2ProviderAppSecrets(ctx, appID)
require.NoError(t, err)
require.Empty(t, secrets)
})
}

func TestOAuth2ProviderTokenExchange(t *testing.T) {
Expand Down
15 changes: 15 additions & 0 deletions coderd/oauth2provider/app_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg
})
)
defer commitAudit()

// A public client authenticates with PKCE alone, so the token endpoint
// never validates a secret for one. Minting a secret anyway would hand
// an operator a credential that does nothing, and worse, one whose
// deletion looks like a kill switch: for a confidential app deleting a
// secret cascades its tokens away, but a public client's tokens carry a
// NULL app_secret_id, so deleting it revokes nothing.
if app.IsPublic() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot create a client secret for a public OAuth2 app.",
Detail: "Public clients authenticate with PKCE and have no client secret. The client type is fixed at registration.",
})
return
}

secret, err := GenerateSecret()
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Expand Down
29 changes: 26 additions & 3 deletions coderd/oauth2provider/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
SoftwareVersion: app.SoftwareVersion.String,
GrantTypes: slice.StringEnums[codersdk.OAuth2ProviderGrantType](app.GrantTypes),
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](app.ResponseTypes),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(app.TokenEndpointAuthMethod.String),
TokenEndpointAuthMethod: reportedAuthMethod(app),
Scope: app.Scope.String,
Contacts: app.Contacts,
RegistrationAccessToken: registrationToken,
Expand Down Expand Up @@ -262,7 +262,7 @@ func GetClientConfiguration(db database.Store) http.HandlerFunc {
SoftwareVersion: app.SoftwareVersion.String,
GrantTypes: slice.StringEnums[codersdk.OAuth2ProviderGrantType](app.GrantTypes),
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](app.ResponseTypes),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(app.TokenEndpointAuthMethod.String),
TokenEndpointAuthMethod: reportedAuthMethod(app),
Scope: app.Scope.String,
Contacts: app.Contacts,
RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security
Expand Down Expand Up @@ -418,7 +418,7 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger
SoftwareVersion: updatedApp.SoftwareVersion.String,
GrantTypes: slice.StringEnums[codersdk.OAuth2ProviderGrantType](updatedApp.GrantTypes),
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](updatedApp.ResponseTypes),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(updatedApp.TokenEndpointAuthMethod.String),
TokenEndpointAuthMethod: reportedAuthMethod(updatedApp),
Scope: updatedApp.Scope.String,
Contacts: updatedApp.Contacts,
RegistrationAccessToken: "", // RFC 7592: Not returned for security
Expand Down Expand Up @@ -570,6 +570,29 @@ func RequireRegistrationAccessToken(db database.Store) func(http.Handler) http.H

// Helper functions for RFC 7591 Dynamic Client Registration

// reportedAuthMethod returns the token_endpoint_auth_method to report for an
// app, which is the stored value unless it contradicts the client type.
//
// The token endpoint enforces on client_type, so reporting a stored method that
// disagrees with it would tell a client to authenticate in a way the server
// will not accept. Clients registered before the type was derived from the
// method can disagree, because the method was persisted verbatim while the type
// was always "confidential": such an app is stored confidential with a method of
// "none", and reporting "none" tells it to drop a secret its exchange still
// requires. Reporting the enforced behavior instead also lets the row repair
// itself, since the client's next PUT sends back a method that matches.
func reportedAuthMethod(app database.OAuth2ProviderApp) codersdk.OAuth2TokenEndpointAuthMethod {
stored := codersdk.OAuth2TokenEndpointAuthMethod(app.TokenEndpointAuthMethod.String)
if stored.Valid() && (stored == codersdk.OAuth2TokenEndpointAuthMethodNone) == app.IsPublic() {
return stored
}
if app.IsPublic() {
return codersdk.OAuth2TokenEndpointAuthMethodNone
}
// RFC 7591 §2 default for a client that authenticates with a secret.
return codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic
}

// generateClientCredentials generates a client secret for OAuth2 apps
func generateClientCredentials() (plaintext string, hashed []byte, err error) {
// Use the same pattern as existing OAuth2 app secrets
Expand Down
3 changes: 3 additions & 0 deletions coderd/oauth2provider/registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ func TestCreateDynamicClientRegistration_DCREnabled(t *testing.T) {
}
}

// TestCreateDynamicClientRegistration_ClientType verifies that whether a
// client_secret is minted, and what client_type is persisted, follows the
// requested token_endpoint_auth_method (RFC 7591 §2, OAuth 2.1 §2.1).
func TestCreateDynamicClientRegistration_ClientType(t *testing.T) {
t.Parallel()

Expand Down
6 changes: 6 additions & 0 deletions docs/admin/integrations/oauth2-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
CODER_EXPERIMENTS=oauth2
```

## Creating OAuth2 Applications

Check warning on line 35 in docs/admin/integrations/oauth2-provider.md

View workflow job for this annotation

GitHub Actions / lint-docs

Coder.GerundHeading

Heading starts with an -ing word ('Creating'); prefer the imperative ('Install') or the noun ('Installation'). See capitalization-and-punctuation.md#no-gerund-leading-headings.

### Method 1: Web UI

Expand Down Expand Up @@ -137,6 +137,12 @@
> `http://` to a non-loopback host is rejected. Confidential clients have
> the same restriction, except they also accept `.localhost` subdomains.

A client's type is fixed when it registers. An RFC 7592 update that would move a client between public and confidential is rejected with `invalid_client_metadata`, since the client either holds a secret that would stop being required or has none and no way to be issued one. Switching between `client_secret_basic` and `client_secret_post` is allowed, because both are confidential. To change type, register a new client.

Clients registered with `token_endpoint_auth_method: none` before Coder honored it are stored as confidential and still require their `client_secret`. Coder reports `client_secret_basic` for those clients so that what it reports matches what it enforces, and the mismatch clears itself the next time the client updates its registration.

Redirect URIs are matched exactly, so register every URI the client will use, including any that differ only by port.

If client authentication fails, the token endpoint returns **HTTP 401** with an OAuth2 `invalid_client` error and a `WWW-Authenticate: Basic realm="coder"` response header.

### Standard OAuth2 Flow
Expand Down Expand Up @@ -333,7 +339,7 @@
This is also how you remove clients that registered themselves while dynamic client registration was enabled.
Turning the setting off stops new registrations; it does not remove the ones already there.

## Testing and Development

Check warning on line 342 in docs/admin/integrations/oauth2-provider.md

View workflow job for this annotation

GitHub Actions / lint-docs

Coder.GerundHeading

Heading starts with an -ing word ('Testing'); prefer the imperative ('Install') or the noun ('Installation'). See capitalization-and-punctuation.md#no-gerund-leading-headings.

Coder provides comprehensive test scripts for OAuth2 development:

Expand Down
Loading