Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7e3f6c0
fix(coderd/oauth2provider): reject PKCE code_verifier below RFC 7636 …
BobbyHo Aug 10, 2026
a125238
fix(scripts/oauth2): generate PKCE verifiers at the RFC 7636 floor le…
BobbyHo Aug 10, 2026
eea4094
fix(coderd/oauth2provider): validate code_challenge format at authorize
BobbyHo Aug 10, 2026
e7d78d5
fix(coderd/oauth2provider): return invalid_request for malformed code…
BobbyHo Aug 10, 2026
fb7e90b
fix(coderd/oauth2provider/oauth2providertest): restore e2e coverage o…
BobbyHo Aug 11, 2026
4fe0b1b
fix(coderd/oauth2provider): revoke authorization code on PKCE failure
BobbyHo Aug 11, 2026
663865a
fix: resolve remaining coder-agents-review findings on PKCE hardening
BobbyHo Aug 11, 2026
912ce41
fix(docs/admin): document PKCE length and charset requirement
BobbyHo Aug 11, 2026
9440708
fix: allow bare custom-scheme redirects for public clients
BobbyHo Aug 11, 2026
450d037
fix(codersdk/oauth2_validation): state the real reason for the mailto…
BobbyHo Aug 12, 2026
8c4a1c0
feat: derive OAuth2 client type from token_endpoint_auth_method
BobbyHo Aug 11, 2026
1821ad4
fix: allow bare custom-scheme redirects for public clients (#28041)
BobbyHo Aug 12, 2026
c8b703d
Merge branch 'oauth2-pkce-verifier-length' into oauth2-public-clients…
BobbyHo Aug 12, 2026
39e4beb
fix: pin OAuth2 client type across RFC 7592 updates
BobbyHo Aug 12, 2026
df7135d
Merge remote-tracking branch 'origin/main' into oauth2-public-clients…
BobbyHo Aug 12, 2026
4725dec
Merge branch 'main' into oauth2-public-clients-vocabulary
BobbyHo Aug 13, 2026
6e510b7
Merge branch 'main' into oauth2-public-clients-vocabulary
BobbyHo Aug 17, 2026
6acd7bc
docs(coderd/oauth2provider): use plainer wording in the client type c…
BobbyHo Aug 17, 2026
38c3353
Merge branch 'main' into oauth2-public-clients-vocabulary
BobbyHo Aug 17, 2026
83c02d7
Merge branch 'main' into oauth2-public-clients-vocabulary
BobbyHo Aug 18, 2026
e8cfef2
Merge branch 'main' into oauth2-public-clients-vocabulary
BobbyHo Aug 18, 2026
c347f5c
refactor(coderd/oauth2provider): name the client type change conjuncts
BobbyHo Aug 19, 2026
99e26eb
refactor(codersdk): derive token endpoint auth method Valid from the …
BobbyHo Aug 19, 2026
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
20 changes: 20 additions & 0 deletions coderd/database/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
BobbyHo marked this conversation as resolved.
// 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 changes: 8 additions & 0 deletions coderd/database/modelmethods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
BobbyHo marked this conversation as resolved.
Comment thread
BobbyHo marked this conversation as resolved.
// 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()
}
Expand Down
32 changes: 32 additions & 0 deletions coderd/database/modelmethods_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
BobbyHo marked this conversation as resolved.
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()
Expand Down
2 changes: 1 addition & 1 deletion coderd/oauth2provider/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
Icon: req.Icon,
CallbackURL: req.CallbackURL,
RedirectUris: []string{},
ClientType: "confidential",
ClientType: database.OAuth2ProviderAppClientTypeConfidential,
DynamicallyRegistered: sql.NullBool{Bool: false, Valid: true},
ClientIDIssuedAt: sql.NullTime{},
ClientSecretExpiresAt: sql.NullTime{},
Expand Down
48 changes: 40 additions & 8 deletions coderd/oauth2provider/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
Icon: req.LogoURI,
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
RedirectUris: req.RedirectURIs,
ClientType: req.DetermineClientType(),
ClientType: string(req.DetermineClientType()),
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
Expand Down Expand Up @@ -311,17 +311,49 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger
return
}

// A client's type is fixed at registration (RFC 7592 §2.2 permits
// rejecting metadata the server will not accept). Flipping it would
// either drop the secret requirement for a client that has one, or mark
// a client confidential when it has no secret and no way to be issued
// one.
//
// Requiring authMethodChanged means an update that leaves the auth
// method alone is never rejected, so a legacy row whose two columns
// disagree can still manage itself. IsPublic is the reader for the
// stored column so an unrecognized value is treated as confidential
// here exactly as it is at the token endpoint.
storedMethod := codersdk.OAuth2TokenEndpointAuthMethod(existingApp.TokenEndpointAuthMethod.String)
authMethodChanged := req.TokenEndpointAuthMethod != storedMethod
clientTypeChanged := (req.DetermineClientType() == codersdk.OAuth2ClientTypePublic) != existingApp.IsPublic()
if authMethodChanged && clientTypeChanged {
logger.Warn(ctx, "rejected oauth2 client type change",
slog.F("client_id", clientID.String()),
slog.F("stored_token_endpoint_auth_method", existingApp.TokenEndpointAuthMethod.String),
slog.F("requested_token_endpoint_auth_method", string(req.TokenEndpointAuthMethod)),
slog.F("stored_client_type", existingApp.ClientType))
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
"invalid_client_metadata",
fmt.Sprintf("token_endpoint_auth_method cannot move an existing client between public and confidential (stored %q, requested %q); the client type is fixed at registration, so register a new client instead",
existingApp.TokenEndpointAuthMethod.String, string(req.TokenEndpointAuthMethod)))
return
}

// Update app in database
now := dbtime.Now()
//nolint:gocritic // OAuth2 system context — RFC 7592 client configuration endpoint
updatedApp, err := db.UpdateOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), database.UpdateOAuth2ProviderAppByClientIDParams{
ID: clientID,
UpdatedAt: now,
Name: req.GenerateClientName(),
Icon: req.LogoURI,
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
RedirectUris: req.RedirectURIs,
ClientType: req.DetermineClientType(),
ID: clientID,
UpdatedAt: now,
Name: req.GenerateClientName(),
Icon: req.LogoURI,
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
RedirectUris: req.RedirectURIs,
// Carried through unchanged. The guard above rejects a request that
// would change the type, so re-deriving it here could only ever
// differ for a legacy row whose stored type and auth method
// disagree, silently converting it to public while it still holds a
// secret.
ClientType: existingApp.ClientType,
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
GrantTypes: slice.ToStrings(req.GrantTypes),
ResponseTypes: slice.ToStrings(req.ResponseTypes),
Expand Down
Loading
Loading