X Tutup
Skip to content

fix(coderd/oauth2provider): support client_secret_basic client auth#21793

Merged
ThomasK33 merged 4 commits intomainfrom
auth-1pd5
Feb 2, 2026
Merged

fix(coderd/oauth2provider): support client_secret_basic client auth#21793
ThomasK33 merged 4 commits intomainfrom
auth-1pd5

Conversation

@ThomasK33
Copy link
Member

Adds RFC 6749 §2.3.1 client_secret_basic support to Coder's OAuth2 provider.

  • Accept client_id from HTTP Basic auth for OAuth2 app lookup middleware.
  • Allow /oauth2/tokens to authenticate clients via Authorization: Basic.
    • If both body creds and header creds are provided and conflict, return
      invalid_request.
  • Advertise client_secret_basic + client_secret_post in discovery metadata.
  • Add WWW-Authenticate header for 401 invalid_client responses.
  • Add regression tests for Basic auth token exchange + metadata.

📋 Implementation Plan

Plan: Add OAuth2 client_secret_basic (HTTP Basic auth) support

Context / why

codex mcp add is failing during the OAuth2 token exchange with:

  • invalid_request: Missing client_id parameter

Coder’s OAuth2 provider currently identifies the client (client_id) and authenticates it (client_secret) only via query/form parameters, but RFC 6749 §2.3.1 requires supporting HTTP Basic authentication for confidential clients. This is especially important because Dynamic Client Registration (RFC 7591) defaults token_endpoint_auth_method to client_secret_basic.

Evidence (what we verified)

  • coderd/httpmw/oauth2.go: extractOAuth2ProviderAppBase checks client_id only in query + form body and returns Missing client_id parameter otherwise.
  • coderd/oauth2provider/tokens.go: extractTokenRequest requires client_id and client_secret as form fields for authorization_code grant.
  • coderd/oauth2provider/metadata.go: discovery metadata advertises only client_secret_post today.
  • User-provided curl shows /oauth2/tokens correctly parses form bodies (returns unsupported_grant_type when only client_id is supplied), so the failure is specifically “client_id not found where Coder looks for it”.

Goals

  • Support OAuth2 client authentication via HTTP Basic on /oauth2/tokens (and other OAuth2 endpoints that expect client auth).
  • Keep existing client_secret_post behavior working.
  • Make discovery metadata match reality: advertise both client_secret_basic and client_secret_post.
  • Improve RFC compliance for error responses: invalid_client with HTTP 401 should include WWW-Authenticate.

Non-goals

  • Adding new OAuth2 grant types.
  • Changing client_id format (it remains the app UUID).
  • Reworking public-client (token_endpoint_auth_method=none) behavior beyond what’s needed for Basic auth.

Implementation plan

1) Parse client_id from HTTP Basic in the OAuth2 app lookup middleware

File: coderd/httpmw/oauth2.go

Target: extractOAuth2ProviderAppBase

Change: when client_id is not present in URL param, query string, or form body, fall back to HTTP Basic username.

paramAppID := r.URL.Query().Get("client_id")
if paramAppID == "" {
	if r.ParseForm() == nil {
		paramAppID = r.Form.Get("client_id")
	}
}
if paramAppID == "" {
	// RFC 6749 §2.3.1: support HTTP Basic client authentication.
	if user, _, ok := r.BasicAuth(); ok && user != "" {
		paramAppID = user
	}
}
if paramAppID == "" {
	errWriter.writeMissingClientID(ctx, rw)
	return
}

2) Support client_secret_basic during token exchange

File: coderd/oauth2provider/tokens.go

Targets: extractTokenRequest, and its callsite in Tokens().

Change: after parsing the form, merge credentials from form + Basic auth:

  • basicUser/basicPass := r.BasicAuth()
  • formClientID := vals.Get("client_id"), formClientSecret := vals.Get("client_secret")
  • If Basic auth is present:
    • Prefer Basic auth values.
    • If both Basic and form provide a value and they conflict, return invalid_request (defensive + avoids ambiguous auth).
  • Validate required fields based on grant_type, but validate the merged client_id/client_secret instead of requiring they exist in the form.
Sketch of the intended logic
user, pass, basicOK := r.BasicAuth()
clientID := vals.Get("client_id")
clientSecret := vals.Get("client_secret")

if basicOK {
	if clientID != "" && clientID != user {
		// invalid_request: conflicting client_id
	}
	if clientSecret != "" && clientSecret != pass {
		// invalid_request: conflicting client_secret
	}
	clientID = user
	clientSecret = pass
}

// Now do grant-type-specific required checks:
// - authorization_code: require code + clientID + clientSecret
// - refresh_token: keep existing behavior unless we decide to tighten it

3) Update discovery metadata to advertise Basic auth support

File: coderd/oauth2provider/metadata.go

Change:

  • Today: TokenEndpointAuthMethodsSupported: [client_secret_post]
  • After: TokenEndpointAuthMethodsSupported: [client_secret_basic, client_secret_post]

4) Add RFC-required WWW-Authenticate header for invalid_client

File: coderd/httpapi/httpapi.go

Target: WriteOAuth2Error

Change: When writing an OAuth2 error with status==401 and error==invalid_client, set:

rw.Header().Set("WWW-Authenticate", `Basic realm=\"coder\"`)

This aligns with RFC 6749 §5.2 (“MUST include WWW-Authenticate when using 401”).

5) Tests

Add/extend tests to prevent regressions:

  1. Token endpoint accepts Basic auth

    • Create OAuth2 app + secret.
    • Complete authorization-code issuance (existing test patterns already do this).
    • Exchange code at /oauth2/tokens while:
      • Setting req.SetBasicAuth(clientID, clientSecret)
      • Omitting client_id + client_secret from the form body
    • Expect HTTP 200.
  2. Invalid secret via Basic returns invalid_client and includes WWW-Authenticate

    • Same setup, but send wrong password.
    • Expect HTTP 401, OAuth2 error invalid_client, and WWW-Authenticate: Basic realm="coder".
  3. Metadata advertises both auth methods

    • Update coderd/oauth2provider/metadata_test.go to assert
      TokenEndpointAuthMethodsSupported contains both client_secret_basic and client_secret_post.

Acceptance criteria

  • codex mcp add ... succeeds end-to-end against a Coder instance with OAuth2 provider enabled.
  • /oauth2/tokens works for both:
    • client_secret_post (existing behavior)
    • client_secret_basic (new behavior)
  • .well-known/oauth-authorization-server includes token_endpoint_auth_methods_supported with client_secret_basic.
  • invalid_client responses return 401 + a WWW-Authenticate header.

Generated with mux • Model: openai:gpt-5.2 • Thinking: xhigh

@coder-tasks
Copy link
Contributor

coder-tasks bot commented Jan 30, 2026

Documentation Check

Updates Needed

  • docs/admin/integrations/oauth2-provider.md - Update the "Token Exchange" example (lines 84-95) to show both authentication methods:
    • Add a section demonstrating HTTP Basic authentication (the new client_secret_basic method)
    • Keep the existing form-based example (client_secret_post)
    • Explain when to use each method per RFC 7591 (Basic is the default for confidential clients)
  • docs/admin/integrations/oauth2-provider.md - Update the "Refresh Tokens" example (lines 153-160) to show both authentication methods
  • docs/admin/integrations/oauth2-provider.md - Add a "Client Authentication Methods" section explaining:
    • client_secret_basic: HTTP Basic authentication (RFC 6749 §2.3.1) - the recommended default
    • client_secret_post: Form-based authentication - for clients that cannot use Basic auth
    • When each method should be used
    • The WWW-Authenticate header in 401 responses

Rationale

This PR adds RFC 6749 §2.3.1 client_secret_basic support (HTTP Basic authentication for OAuth2 token requests). The changes are user-facing:

  1. Breaking change for some clients: RFC 7591 specifies client_secret_basic as the default token_endpoint_auth_method. Clients relying on Dynamic Client Registration will now default to Basic auth unless they explicitly request client_secret_post.

  2. New authentication method: Users can now authenticate using Authorization: Basic header instead of form parameters when exchanging tokens. This is the RFC-recommended approach and what many OAuth2 libraries expect.

  3. Standards compliance: The discovery metadata now advertises both methods. This affects how clients integrate with Coder's OAuth2 provider.

The documentation currently only shows form-based authentication (client_secret_post). Users need examples of both methods to understand which to use and how to implement them correctly.


Automated review via Coder Tasks

@ThomasK33 ThomasK33 changed the title fix(oauth2): support client_secret_basic client auth fix(coderd/oauth2provider): support client_secret_basic client auth Jan 30, 2026
@ThomasK33
Copy link
Member Author

@coder-tasks Thanks! Updated docs in docs/admin/integrations/oauth2-provider.md to cover both client_secret_basic and client_secret_post for token exchange + refresh tokens, added a Client Authentication Methods section, and documented the WWW-Authenticate header on 401 invalid_client.

Re: breaking change concern — Dynamic Client Registration already defaulted token_endpoint_auth_method to client_secret_basic (per RFC 7591) in OAuth2ClientRegistrationRequest.ApplyDefaults(). This PR makes that default actually work by adding client_secret_basic support; existing clients using client_secret_post continue to work (we still accept post and do not require clients to switch auth methods).

@ThomasK33 ThomasK33 marked this pull request as draft January 30, 2026 16:04
Copy link
Contributor

@dannykopping dannykopping left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Change-Id: I8e0ff01588c2208a506ea278d1b1d6d1a7f4fd4c
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: If64da5088deff45313105e21f5ef478274bb5e6c
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I2f9cd1151557827a221d2e960169faa962350b18
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I72c0e6fe3213b46e7b7fb48427e8542529d76ef5
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33 ThomasK33 marked this pull request as ready for review February 2, 2026 09:01
@ThomasK33 ThomasK33 merged commit dd6aec0 into main Feb 2, 2026
31 checks passed
@ThomasK33 ThomasK33 deleted the auth-1pd5 branch February 2, 2026 15:01
@github-actions github-actions bot locked and limited conversation to collaborators Feb 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

X Tutup