feat: derive OAuth2 client type from token_endpoint_auth_method (#28043)

Adds an OAuth2 client type (public vs confidential, RFC 7591 §2) derived
from the requested auth method instead of hardcoded confidential. The
type is stored and guarded here, but no endpoint enforces on it yet;
public behavior at the token endpoint follows in the next PR in the
stack.

- Client type is derived once and reused by both registration and
redirect URI validation, so they can't disagree
- IsPublic() fails closed: an unrecognized or missing value reads as
confidential
- RFC 7592 update (PUT) now rejects moving a client between public and
confidential (400) instead of silently flipping it when the auth method
is omitted
- Discovery still doesn't advertise "none"; follows once the token
endpoint honors it

### Behavior by client shape

`client_type` is derived from `token_endpoint_auth_method` at POST and
pinned at PUT. RFC 7592 GET/PUT authenticate with the registration
access token, not the client secret, so neither endpoint reads a secret.

| Registered with | Stored `client_type` / method | GET reports | PUT
that flips the method |

|------------------------------------|---------------------------------------|-----------------------|-----------------------------------------|
| omitted, or `client_secret_basic` | `confidential` /
`client_secret_basic` | `client_secret_basic` | `none` → 400
`invalid_client_metadata` |
| `none` (new) | `public` / `none` | `none` | `client_secret_*` → 400
`invalid_client_metadata` |
| `none` (before this PR) | `confidential` / `none` | `none` | either →
200, type stays `confidential` |

- PUT still replaces every other RFC 7591 field. `client_type` is the
only pinned one; the method may move within a type
(`client_secret_basic` ↔ `client_secret_post`).
- Row 3 is the only shape where the two columns disagree. The guard
fires only on a method change that crosses the type line, so those
clients keep managing themselves instead of being locked out of their
own configuration endpoint.
- The token endpoint does not consult `client_type` yet, so every client
still authenticates with a secret and registration still issues one.

Split out of #27873, second in the stack (on top of #28041).
Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
This commit is contained in:
Bobby Ho
2026-08-18 21:12:44 -07:00
committed by GitHub
parent 4d13bef74d
commit 663f41ffa9
11 changed files with 526 additions and 27 deletions
+20
View File
@@ -10,3 +10,23 @@ import (
// for use as a uuid.UUID. Both must agree; tests pin the value to the
// codersdk constant so the two cannot drift.
var PrebuildsSystemUserID = uuid.MustParse(codersdk.PrebuildsSystemUserID)
// Values stored in oauth2_provider_apps.client_type, as plain strings for
// comparison against the sqlc-generated string column.
//
// Converted from the codersdk constants rather than redeclared, so the value
// registration writes and the value OAuth2ProviderApp.IsPublic reads back
// cannot disagree. That divergence would fail closed anyway (the app would read
// as confidential and demand a secret it was never issued), but it would fail
// visibly to a client rather than here.
//
// What this does not protect against is the two constants colliding on the same
// value, which would make IsPublic true for confidential apps. Nothing in the
// type system can catch that; the tests that pin these spellings to the wire
// values do, so do not delete them as redundant:
// TestOAuth2ClientRegistrationRequest_DetermineClientType (codersdk) and
// TestOAuth2ProviderAppIsPublic (coderd/database).
const (
OAuth2ProviderAppClientTypeConfidential = string(codersdk.OAuth2ClientTypeConfidential)
OAuth2ProviderAppClientTypePublic = string(codersdk.OAuth2ClientTypePublic)
)
+8
View File
@@ -685,6 +685,14 @@ func (OAuth2ProviderApp) RBACObject() rbac.Object {
return rbac.ResourceOauth2App
}
// IsPublic reports whether the app is a public (secretless, PKCE-only)
// OAuth2 client per RFC 7591 §2 / OAuth 2.1 §2.1, as opposed to confidential.
// An unset or unrecognized client type reads as confidential, so an app can
// never skip client authentication by accident.
func (a OAuth2ProviderApp) IsPublic() bool {
return a.ClientType == OAuth2ProviderAppClientTypePublic
}
func (a GetOAuth2ProviderAppsByUserIDRow) RBACObject() rbac.Object {
return a.OAuth2ProviderApp.RBACObject()
}
@@ -221,6 +221,38 @@ func TestWorkspaceACLDisabled(t *testing.T) {
})
}
// TestOAuth2ProviderAppIsPublic pins IsPublic's contract directly, since it is
// what decides whether the token endpoint validates a client secret at all.
// Only the exact string "public" may read as public: anything else, including
// an unset column or a differently-cased value, must read as confidential so
// that a garbled value cannot silently skip client authentication.
func TestOAuth2ProviderAppIsPublic(t *testing.T) {
t.Parallel()
tests := []struct {
name string
clientType string
want bool
}{
{name: "Public", clientType: "public", want: true},
{name: "Confidential", clientType: "confidential", want: false},
{name: "Empty", clientType: "", want: false},
{name: "MixedCasePublic", clientType: "Public", want: false},
{name: "AllCapsPublic", clientType: "PUBLIC", want: false},
{name: "LeadingSpace", clientType: " public", want: false},
{name: "TrailingSpace", clientType: "public ", want: false},
{name: "Bogus", clientType: "bogus", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
app := OAuth2ProviderApp{ClientType: tt.clientType}
require.Equal(t, tt.want, app.IsPublic())
})
}
}
// Helpers
func requirePermission(t *testing.T, s rbac.Scope, resource string, action policy.Action) {
t.Helper()