Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
7c124e7
feat(coderd/oauth2provider): register public clients without a secret
BobbyHo Aug 11, 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
9198095
Merge branch 'oauth2-public-clients-vocabulary' into oauth2-public-cl…
BobbyHo Aug 17, 2026
432b2a5
docs: correct the public client token endpoint and redirect URI claims
BobbyHo Aug 17, 2026
6acd7bc
docs(coderd/oauth2provider): use plainer wording in the client type c…
BobbyHo Aug 17, 2026
794ed0d
Merge branch 'oauth2-public-clients-vocabulary' into oauth2-public-cl…
BobbyHo Aug 17, 2026
38c3353
Merge branch 'main' into oauth2-public-clients-vocabulary
BobbyHo Aug 17, 2026
21fdfed
Merge branch 'oauth2-public-clients-vocabulary' into oauth2-public-cl…
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
f3e05bd
Merge remote-tracking branch 'origin/oauth2-public-clients-vocabulary…
BobbyHo Aug 19, 2026
83a6495
Merge remote-tracking branch 'origin/main' into oauth2-public-clients…
BobbyHo Aug 19, 2026
3d40daa
Merge branch 'main' into oauth2-public-clients-registration
BobbyHo Aug 19, 2026
24daec0
Merge branch 'main' into oauth2-public-clients-registration
BobbyHo Aug 19, 2026
e81ed36
Merge branch 'main' into oauth2-public-clients-registration
BobbyHo Aug 20, 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: 11 additions & 9 deletions coderd/oauth2provider/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@ func GetAuthorizationServerMetadata(db database.Store, accessURL *url.URL) http.
}

metadata := codersdk.OAuth2AuthorizationServerMetadata{
Issuer: accessURL.String(),
AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(),
TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(),
RevocationEndpoint: accessURL.JoinPath("/oauth2/revoke").String(), // RFC 7009
ResponseTypesSupported: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
GrantTypesSupported: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
CodeChallengeMethodsSupported: []codersdk.OAuth2PKCECodeChallengeMethod{codersdk.OAuth2PKCECodeChallengeMethodS256},
ScopesSupported: rbac.ExternalScopeNames(),
TokenEndpointAuthMethodsSupported: []codersdk.OAuth2TokenEndpointAuthMethod{codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost},
Issuer: accessURL.String(),
AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(),
TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(),
RevocationEndpoint: accessURL.JoinPath("/oauth2/revoke").String(), // RFC 7009
ResponseTypesSupported: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
GrantTypesSupported: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
CodeChallengeMethodsSupported: []codersdk.OAuth2PKCECodeChallengeMethod{codersdk.OAuth2PKCECodeChallengeMethodS256},
ScopesSupported: rbac.ExternalScopeNames(),
// Not gated on dcrEnabled: existing clients still need to
// exchange tokens when new registrations are turned off.
TokenEndpointAuthMethodsSupported: codersdk.AdvertisedOAuth2TokenEndpointAuthMethods(),
}
if dcrEnabled {
metadata.RegistrationEndpoint = accessURL.JoinPath("/oauth2/register").String() // RFC 7591
Expand Down
5 changes: 5 additions & 0 deletions coderd/oauth2provider/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
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)
}
Expand Down
20 changes: 20 additions & 0 deletions coderd/oauth2provider/oauth2providertest/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ func CreateTestOAuth2App(t *testing.T, client *codersdk.Client) (*codersdk.OAuth
return &app, secret.ClientSecretFull
}

// RegisterPublicClient registers a public (secretless, PKCE-only) OAuth2 client
// via RFC 7591 dynamic registration, the only way to create one. This is the
// public counterpart to CreateTestOAuth2App. The caller must call EnableDCR
// first, and needs owner-level permissions to do so.
func RegisterPublicClient(t *testing.T, client *codersdk.Client, name, redirectURI string) codersdk.OAuth2ClientRegistrationResponse {
t.Helper()

ctx := testutil.Context(t, testutil.WaitLong)
resp, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{redirectURI},
ClientName: fmt.Sprintf("%s-%s", name, testutil.MustRandString(t, 10)),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethodNone,
})
require.NoError(t, err, "failed to register public OAuth2 client")
// A public client is issued no secret. Asserting it here means every caller
// inherits the check rather than restating it.
require.Empty(t, resp.ClientSecret, "public client must not be issued a secret")
return resp
}

// EnableDCR turns on dynamic client registration for the deployment.
// DCR defaults to disabled, so any test that registers a client via
// POST /oauth2/register must call this first. The caller-provided client
Expand Down
18 changes: 18 additions & 0 deletions coderd/oauth2provider/oauth2providertest/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -634,3 +634,21 @@ func TestOAuth2ErrorResponses(t *testing.T) {
)
})
}

// TestOAuth2RegisterPublicClient exercises the RegisterPublicClient helper
// end-to-end against a real server: registering with token_endpoint_auth_
// method "none" issues no client secret. A bug in the helper's request
// shape or assertions would otherwise ride uncaught until a later PR's test
// happened to call it.
func TestOAuth2RegisterPublicClient(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
oauth2providertest.EnableDCR(t, client)

resp := oauth2providertest.RegisterPublicClient(t, client, "test-public-client", "https://example.com/callback")
require.NotEmpty(t, resp.ClientID)
}
151 changes: 87 additions & 64 deletions coderd/oauth2provider/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,22 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
// Apply defaults
req = req.ApplyDefaults()

// Generate client credentials
clientType := req.DetermineClientType()
isPublic := clientType == codersdk.OAuth2ClientTypePublic

Comment thread
BobbyHo marked this conversation as resolved.
// Public clients authenticate with PKCE alone and never receive a
// secret (RFC 7591 §2, OAuth 2.1 §2.1).
clientID := uuid.New()
clientSecret, hashedSecret, err := generateClientCredentials()
if err != nil {
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
"server_error", "Failed to generate client credentials")
return
var clientSecret string
var hashedSecret []byte
if !isPublic {
var err error
clientSecret, hashedSecret, err = generateClientCredentials()
if err != nil {
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
"server_error", "Failed to generate client credentials")
return
}
}

// Generate registration access token for RFC 7592 management
Expand All @@ -92,35 +101,72 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
// Store in database - use system context since this is a public endpoint
now := dbtime.Now()
clientName := req.GenerateClientName()
//nolint:gocritic // OAuth2 system context — dynamic registration is a public endpoint
app, err := db.InsertOAuth2ProviderApp(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppParams{
ID: clientID,
CreatedAt: now,
UpdatedAt: now,
Name: clientName,
Icon: req.LogoURI,
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
RedirectUris: req.RedirectURIs,
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
GrantTypes: slice.ToStrings(req.GrantTypes),
ResponseTypes: slice.ToStrings(req.ResponseTypes),
TokenEndpointAuthMethod: sql.NullString{String: string(req.TokenEndpointAuthMethod), Valid: true},
Scope: sql.NullString{String: req.Scope, Valid: true},
Contacts: req.Contacts,
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""},
TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""},
PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""},
JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""},
Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0},
SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""},
SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""},
RegistrationAccessToken: hashedRegToken,
RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", accessURL.String(), clientID), Valid: true},
})
// The app and its secret are written in one transaction. A partial
// write would commit an app that can never authenticate, and which
// still holds a registration access token.
Comment thread
BobbyHo marked this conversation as resolved.
var app database.OAuth2ProviderApp
err = db.InTx(func(tx database.Store) error {
Comment thread
BobbyHo marked this conversation as resolved.
var err error
//nolint:gocritic // OAuth2 system context, dynamic registration is a public endpoint
Comment thread
BobbyHo marked this conversation as resolved.
app, err = tx.InsertOAuth2ProviderApp(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppParams{
ID: clientID,
CreatedAt: now,
UpdatedAt: now,
Name: clientName,
Icon: req.LogoURI,
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
RedirectUris: req.RedirectURIs,
ClientType: string(clientType),
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
GrantTypes: slice.ToStrings(req.GrantTypes),
ResponseTypes: slice.ToStrings(req.ResponseTypes),
TokenEndpointAuthMethod: sql.NullString{String: string(req.TokenEndpointAuthMethod), Valid: true},
Scope: sql.NullString{String: req.Scope, Valid: true},
Contacts: req.Contacts,
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""},
TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""},
PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""},
JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""},
Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0},
SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""},
SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""},
RegistrationAccessToken: hashedRegToken,
// JoinPath, not Sprintf: an access URL configured with a
// trailing slash would otherwise mint "//oauth2/clients/{id}"
// and hand it to the client as its management endpoint.
RegistrationClientUri: sql.NullString{String: accessURL.JoinPath("/oauth2/clients", clientID.String()).String(), Valid: true},
Comment thread
BobbyHo marked this conversation as resolved.
})
if err != nil {
return xerrors.Errorf("insert oauth2 provider app: %w", err)
}

if isPublic {
return nil
}

// Extract the prefix for the secret row below.
parsedSecret, err := ParseFormattedSecret(clientSecret)
if err != nil {
return xerrors.Errorf("parse generated secret: %w", err)
}

//nolint:gocritic // OAuth2 system context, dynamic registration is a public endpoint
_, err = tx.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppSecretParams{
ID: uuid.New(),
CreatedAt: now,
SecretPrefix: []byte(parsedSecret.Prefix),
HashedSecret: hashedSecret,
DisplaySecret: createDisplaySecret(clientSecret),
AppID: clientID,
})
if err != nil {
return xerrors.Errorf("insert oauth2 provider app secret: %w", err)
}
return nil
}, nil)
if err != nil {
logger.Error(ctx, "failed to store oauth2 client registration",
slog.Error(err),
Expand All @@ -132,29 +178,6 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
return
}

// Create client secret - parse the formatted secret to get components
parsedSecret, err := ParseFormattedSecret(clientSecret)
if err != nil {
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
"server_error", "Failed to parse generated secret")
return
}

//nolint:gocritic // OAuth2 system context — dynamic registration is a public endpoint
_, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppSecretParams{
ID: uuid.New(),
CreatedAt: now,
SecretPrefix: []byte(parsedSecret.Prefix),
HashedSecret: hashedSecret,
DisplaySecret: createDisplaySecret(clientSecret),
AppID: clientID,
})
if err != nil {
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
"server_error", "Failed to store client secret")
return
}

// Set audit log data
aReq.New = app

Expand Down Expand Up @@ -202,7 +225,7 @@ func GetClientConfiguration(db database.Store) http.HandlerFunc {
}

// Get app by client ID
//nolint:gocritic // OAuth2 system context RFC 7592 client configuration endpoint
//nolint:gocritic // OAuth2 system context, RFC 7592 client configuration endpoint
app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), clientID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -288,7 +311,7 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger
req = req.ApplyDefaults()

// Get existing app to verify it exists and is dynamically registered
//nolint:gocritic // OAuth2 system context RFC 7592 client configuration endpoint
//nolint:gocritic // OAuth2 system context, RFC 7592 client configuration endpoint
existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), clientID)
if err == nil {
aReq.Old = existingApp
Expand Down Expand Up @@ -340,7 +363,7 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger

// Update app in database
now := dbtime.Now()
//nolint:gocritic // OAuth2 system context RFC 7592 client configuration endpoint
//nolint:gocritic // OAuth2 system context, RFC 7592 client configuration endpoint
updatedApp, err := db.UpdateOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), database.UpdateOAuth2ProviderAppByClientIDParams{
ID: clientID,
UpdatedAt: now,
Expand Down Expand Up @@ -428,7 +451,7 @@ func DeleteClientConfiguration(db database.Store, auditor *audit.Auditor, logger
}

// Get existing app to verify it exists and is dynamically registered
//nolint:gocritic // OAuth2 system context RFC 7592 client configuration endpoint
//nolint:gocritic // OAuth2 system context, RFC 7592 client configuration endpoint
existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), clientID)
if err == nil {
aReq.Old = existingApp
Expand All @@ -452,7 +475,7 @@ func DeleteClientConfiguration(db database.Store, auditor *audit.Auditor, logger
}

// Delete the client and all associated data (tokens, secrets, etc.)
//nolint:gocritic // OAuth2 system context RFC 7592 client configuration endpoint
//nolint:gocritic // OAuth2 system context, RFC 7592 client configuration endpoint
err = db.DeleteOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), clientID)
if err != nil {
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
Expand Down Expand Up @@ -504,7 +527,7 @@ func RequireRegistrationAccessToken(db database.Store) func(http.Handler) http.H
}

// Get the client and verify the registration access token
//nolint:gocritic // OAuth2 system context RFC 7592 registration access token validation
//nolint:gocritic // OAuth2 system context, RFC 7592 registration access token validation
app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), clientID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
Expand Down
Loading
Loading