From 8b0eb4567d7697ca1bd2abdf5b73ec14dbf65750 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 6 Aug 2026 11:54:52 -0700 Subject: [PATCH 01/10] feat(coderd/database): constrain the OAuth2 client type column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client_type decides whether the token endpoint validates a client secret at all, and the column accepted any text: nullable, no CHECK, no enum. No Go path can write a bad value, and IsPublic fails closed on anything unrecognized, so the read side is safe today. The point is what the schema still permits: a future migration writing 'public' onto a row that holds a secret turns off client authentication for that app with nothing to catch it, no constraint, no log, no audit entry, no test. Two columns also describe the same fact and could contradict each other. token_endpoint_auth_method is the client's own declaration, registered metadata under RFC 7591 §2 where "none" means the client is public and has no secret; client_type is the derived value enforced on. Registration used to persist the declaration verbatim while hardcoding client_type, so rows exist declaring "none" on a client stored confidential that holds a real secret. A client reading its own metadata concludes it is public, drops the secret, and stops being able to exchange codes. The backfill aligns the declaration to what is enforced; deriving enforcement from the declaration instead would reclassify those clients as public and stop requiring the secret they were issued. SET NOT NULL changes the generated field from sql.NullString to string, so the three write sites are updated to match. That is the whole application change; no behavior depends on it. Co-Authored-By: Claude Opus 5 (1M context) --- coderd/database/check_constraint.go | 1 + coderd/database/dbgen/dbgen.go | 2 +- coderd/database/dump.sql | 5 +-- ...565_oauth2_client_type_constraint.down.sql | 5 +++ ...00565_oauth2_client_type_constraint.up.sql | 17 ++++++++++ ...00566_oauth2_auth_method_backfill.down.sql | 11 +++++++ .../000566_oauth2_auth_method_backfill.up.sql | 31 +++++++++++++++++++ coderd/database/models.go | 2 +- coderd/database/queries.sql.go | 6 ++-- coderd/oauth2provider/apps.go | 2 +- coderd/oauth2provider/registration.go | 4 +-- 11 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 coderd/database/migrations/000565_oauth2_client_type_constraint.down.sql create mode 100644 coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql create mode 100644 coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql create mode 100644 coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index dbf0debfcab..268009cd29b 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -44,6 +44,7 @@ const ( CheckMcpServerConfigsAuthTypeCheck CheckConstraint = "mcp_server_configs_auth_type_check" // mcp_server_configs CheckMcpServerConfigsAvailabilityCheck CheckConstraint = "mcp_server_configs_availability_check" // mcp_server_configs CheckMcpServerConfigsTransportCheck CheckConstraint = "mcp_server_configs_transport_check" // mcp_server_configs + CheckOauth2ProviderAppsClientTypeCheck CheckConstraint = "oauth2_provider_apps_client_type_check" // oauth2_provider_apps CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs CheckNatsPortValidTcp CheckConstraint = "nats_port_valid_tcp" // replicas CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 6f48e42182f..10cfe4dddff 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1733,7 +1733,7 @@ func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2Prov Icon: takeFirst(seed.Icon, ""), CallbackURL: takeFirst(seed.CallbackURL, "http://localhost"), RedirectUris: takeFirstSlice(seed.RedirectUris, []string{}), - ClientType: takeFirst(seed.ClientType, sql.NullString{String: "confidential", Valid: true}), + ClientType: takeFirst(seed.ClientType, "confidential"), DynamicallyRegistered: takeFirst(seed.DynamicallyRegistered, sql.NullBool{Bool: false, Valid: true}), ClientIDIssuedAt: takeFirst(seed.ClientIDIssuedAt, sql.NullTime{}), ClientSecretExpiresAt: takeFirst(seed.ClientSecretExpiresAt, sql.NullTime{}), diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 808111c3764..775a2b27b43 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2652,7 +2652,7 @@ CREATE TABLE oauth2_provider_apps ( icon character varying(256) NOT NULL, callback_url text NOT NULL, redirect_uris text[], - client_type text DEFAULT 'confidential'::text, + client_type text DEFAULT 'confidential'::text NOT NULL, dynamically_registered boolean DEFAULT false, client_id_issued_at timestamp with time zone DEFAULT now(), client_secret_expires_at timestamp with time zone, @@ -2670,7 +2670,8 @@ CREATE TABLE oauth2_provider_apps ( software_id text, software_version text, registration_access_token bytea, - registration_client_uri text + registration_client_uri text, + CONSTRAINT oauth2_provider_apps_client_type_check CHECK ((client_type = ANY (ARRAY['confidential'::text, 'public'::text]))) ); COMMENT ON TABLE oauth2_provider_apps IS 'A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.'; diff --git a/coderd/database/migrations/000565_oauth2_client_type_constraint.down.sql b/coderd/database/migrations/000565_oauth2_client_type_constraint.down.sql new file mode 100644 index 00000000000..b5c951c56b4 --- /dev/null +++ b/coderd/database/migrations/000565_oauth2_client_type_constraint.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE oauth2_provider_apps + ALTER COLUMN client_type DROP NOT NULL; + +ALTER TABLE oauth2_provider_apps + DROP CONSTRAINT oauth2_provider_apps_client_type_check; diff --git a/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql b/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql new file mode 100644 index 00000000000..e6ae45e0858 --- /dev/null +++ b/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql @@ -0,0 +1,17 @@ +-- client_type decides whether the token endpoint validates a client secret at +-- all, so a row holding an unrecognized value is a client whose authentication +-- rules are undefined. Constrain it at the schema level: no Go path can write a +-- bad value today, but a future migration writing 'public' onto a row that +-- holds a secret would turn off client authentication for that app with nothing +-- to catch it. +-- +-- This should touch zero rows: migration 000344 added the column with a default +-- of 'confidential' and backfilled existing rows with COALESCE. +UPDATE oauth2_provider_apps SET client_type = 'confidential' WHERE client_type IS NULL; + +ALTER TABLE oauth2_provider_apps + ADD CONSTRAINT oauth2_provider_apps_client_type_check + CHECK (client_type IN ('confidential', 'public')); + +ALTER TABLE oauth2_provider_apps + ALTER COLUMN client_type SET NOT NULL; diff --git a/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql b/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql new file mode 100644 index 00000000000..6f1e376fc45 --- /dev/null +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql @@ -0,0 +1,11 @@ +-- Deliberately empty. +-- +-- The up migration repairs rows whose token_endpoint_auth_method contradicted +-- the client_type the token endpoint enforces on. It does not record which rows +-- it touched, so the previous values cannot be restored, and restoring them +-- would only reinstate metadata that tells a client to authenticate in a way the +-- server rejects. +-- +-- The schema is unchanged either way, so rolling back past this migration needs +-- no structural work. +SELECT 1; diff --git a/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql new file mode 100644 index 00000000000..6e35e6c5bba --- /dev/null +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql @@ -0,0 +1,31 @@ +-- Two columns describe how an OAuth2 client authenticates, and they can +-- currently contradict each other. +-- +-- token_endpoint_auth_method is the client's own declaration, registered client +-- metadata under RFC 7591 section 2, where "none" is defined to mean the client +-- is public and has no secret. client_type is Coder's derived copy of that same +-- fact, and it is what the token endpoint actually enforces on. RFC 7591 defines +-- no client_type metadata field; the column exists only as a denormalization. +-- +-- Registration used to persist the declaration verbatim while hardcoding +-- client_type to 'confidential', so rows exist saying "auth method: none" on a +-- client stored confidential that was issued, and still requires, a real secret. +-- A client that reads its own metadata and believes it is public will drop that +-- secret and stop being able to exchange codes. +-- +-- Align the declaration to what is enforced, not the reverse. Deriving +-- enforcement from the declaration would reclassify every such client as public +-- and stop requiring the secret it holds, which is a silent authentication +-- downgrade. +UPDATE oauth2_provider_apps +SET token_endpoint_auth_method = 'client_secret_basic' -- the RFC 7591 section 2 default for a client with a secret +WHERE client_type = 'confidential' + AND (token_endpoint_auth_method IS NULL OR token_endpoint_auth_method = 'none'); + +-- The mirror case is not reachable through any current code path, since a public +-- client is only ever created by requesting 'none'. Included so the invariant +-- holds for the whole table rather than for the half that had a known bug. +UPDATE oauth2_provider_apps +SET token_endpoint_auth_method = 'none' +WHERE client_type = 'public' + AND (token_endpoint_auth_method IS NULL OR token_endpoint_auth_method <> 'none'); diff --git a/coderd/database/models.go b/coderd/database/models.go index 2a265fc9b1b..a68b7e54bc9 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5525,7 +5525,7 @@ type OAuth2ProviderApp struct { // List of valid redirect URIs for the application RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` // OAuth2 client type: confidential or public - ClientType sql.NullString `db:"client_type" json:"client_type"` + ClientType string `db:"client_type" json:"client_type"` // Whether this app was created via dynamic client registration DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"` // RFC 7591: Timestamp when client_id was issued diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e499234f558..7ea67f2f2a5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -19232,7 +19232,7 @@ type InsertOAuth2ProviderAppParams struct { Icon string `db:"icon" json:"icon"` CallbackURL string `db:"callback_url" json:"callback_url"` RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` - ClientType sql.NullString `db:"client_type" json:"client_type"` + ClientType string `db:"client_type" json:"client_type"` DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"` ClientIDIssuedAt sql.NullTime `db:"client_id_issued_at" json:"client_id_issued_at"` ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"` @@ -19541,7 +19541,7 @@ type UpdateOAuth2ProviderAppByClientIDParams struct { Icon string `db:"icon" json:"icon"` CallbackURL string `db:"callback_url" json:"callback_url"` RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` - ClientType sql.NullString `db:"client_type" json:"client_type"` + ClientType string `db:"client_type" json:"client_type"` ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"` GrantTypes []string `db:"grant_types" json:"grant_types"` ResponseTypes []string `db:"response_types" json:"response_types"` @@ -19647,7 +19647,7 @@ type UpdateOAuth2ProviderAppByIDParams struct { Icon string `db:"icon" json:"icon"` CallbackURL string `db:"callback_url" json:"callback_url"` RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` - ClientType sql.NullString `db:"client_type" json:"client_type"` + ClientType string `db:"client_type" json:"client_type"` DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"` ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"` GrantTypes []string `db:"grant_types" json:"grant_types"` diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go index b25b0f91e85..da590ab0cdb 100644 --- a/coderd/oauth2provider/apps.go +++ b/coderd/oauth2provider/apps.go @@ -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: sql.NullString{String: "confidential", Valid: true}, + ClientType: "confidential", DynamicallyRegistered: sql.NullBool{Bool: false, Valid: true}, ClientIDIssuedAt: sql.NullTime{}, ClientSecretExpiresAt: sql.NullTime{}, diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go index c0d297f6ea9..2261a5c5b51 100644 --- a/coderd/oauth2provider/registration.go +++ b/coderd/oauth2provider/registration.go @@ -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: sql.NullString{String: req.DetermineClientType(), Valid: true}, + ClientType: req.DetermineClientType(), DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true}, ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true}, ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now @@ -321,7 +321,7 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger Icon: req.LogoURI, CallbackURL: req.RedirectURIs[0], // Primary redirect URI RedirectUris: req.RedirectURIs, - ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true}, + ClientType: req.DetermineClientType(), ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now GrantTypes: slice.ToStrings(req.GrantTypes), ResponseTypes: slice.ToStrings(req.ResponseTypes), From c258668757bf599646c499dfcf1262315ca6ab78 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 6 Aug 2026 14:52:51 -0700 Subject: [PATCH 02/10] test(coderd/database/migrations): cover the client type constraint and backfill Addresses CRF-1 on #27931. Neither migration had a dedicated test, and the shared fixture cannot reach the case that matters: testdata/fixtures/ 000182_oauth2_provider.up.sql inserts one app with no token_endpoint_auth_method, so the backfill's IS NULL branch matches it while the '= none' branch, the one repairing the actual bug, matches zero rows in CI. Follows the existing Stepper-to-prior-version, seed, migrate, assert pattern of TestMigration000542ChatReasoningEffortBackfill, TestMigration000562OAuth2PublicClientTokensBackfill and TestMigration000563TemplateAgentsAllowedBackfill. The backfill test seeds all six combinations that matter: a confidential client declaring "none" (the known bug), one with no declaration at all, a public client declaring a secret-based method, and the three already consistent shapes. It asserts client_secret_post is not flattened to basic, that client_type itself is never rewritten, and states the resulting invariant directly by counting rows where the declaration contradicts the enforced type. The constraint test asserts a NULL client_type is backfilled to confidential rather than blocking SET NOT NULL, that the column becomes NOT NULL, and that the CHECK actually rejects "Public", "PUBLIC", "public ", "bogus", "" and NULL while both canonical values remain insertable. Both are mutation-checked. Removing the backfill's '= none' branch fails the legacy case and the invariant count; removing the CHECK fails the rejection assertions. Co-Authored-By: Claude Opus 5 (1M context) --- coderd/database/migrations/migrate_test.go | 227 +++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index cc1beeea3a2..a6938fd1c30 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2739,3 +2739,230 @@ func TestMigration000562OAuth2PublicClientTokensBackfill(t *testing.T) { require.NoError(t, err) require.Equal(t, "YES", isNullable, "app_secret_id should be nullable after the migration") } + +// setupMigration000565Apps steps to the migration just before 000565 and seeds +// oauth2_provider_apps rows covering every combination the two migrations care +// about. Returns the app IDs keyed by the shape they were seeded with. +func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[string]uuid.UUID) { + t.Helper() + + const priorMigrationVersion = 564 + + sqlDB := testSQLDB(t) + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more || version == priorMigrationVersion { + break + } + } + + ctx := testutil.Context(t, testutil.WaitSuperLong) + now := time.Now().UTC().Truncate(time.Microsecond) + + // The shapes that matter. "legacy" is the row the known bug produced: the + // client asked for "none", which RFC 7591 §2 defines as public, but + // client_type was hardcoded confidential and a secret was issued anyway. + ids := map[string]uuid.UUID{ + "legacy": uuid.New(), // confidential + none -> auth method must be repaired + "nullMethod": uuid.New(), // confidential + NULL -> auth method must be repaired + "publicMismatched": uuid.New(), // public + client_secret_basic -> auth method must be repaired + "confidentialBasic": uuid.New(), // already consistent -> must be left alone + "confidentialPost": uuid.New(), // already consistent, and post must not be flattened to basic + "publicNone": uuid.New(), // already consistent -> must be left alone + } + + seed := func(id uuid.UUID, name, clientType string, authMethod *string) { + t.Helper() + _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO oauth2_provider_apps + (id, created_at, updated_at, name, icon, callback_url, client_type, token_endpoint_auth_method) + VALUES ($1, $2, $2, $3, '', 'http://localhost/callback', $4, $5) + `, id, now, name, clientType, authMethod) + require.NoError(t, err) + } + strPtr := func(s string) *string { return &s } + + seed(ids["legacy"], "test-565-legacy", "confidential", strPtr("none")) + seed(ids["nullMethod"], "test-565-null", "confidential", nil) + seed(ids["publicMismatched"], "test-565-public-mismatch", "public", strPtr("client_secret_basic")) + seed(ids["confidentialBasic"], "test-565-basic", "confidential", strPtr("client_secret_basic")) + seed(ids["confidentialPost"], "test-565-post", "confidential", strPtr("client_secret_post")) + seed(ids["publicNone"], "test-565-public", "public", strPtr("none")) + + return sqlDB, ctx, ids +} + +// TestMigration000565OAuth2ClientTypeConstraint covers the constraint migration: +// a NULL client_type is backfilled, the column becomes NOT NULL, and the CHECK +// rejects any value other than the two canonical ones. client_type is what the +// token endpoint reads to decide whether a client secret is validated at all, so +// the point of the constraint is that a value outside that set cannot exist. +func TestMigration000565OAuth2ClientTypeConstraint(t *testing.T) { + t.Parallel() + + sqlDB, ctx, _ := setupMigration000565Apps(t) + now := time.Now().UTC().Truncate(time.Microsecond) + + // A NULL client_type must survive the migration as 'confidential', the + // fail-closed direction, rather than blocking SET NOT NULL. + nullTypeID := uuid.New() + _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO oauth2_provider_apps + (id, created_at, updated_at, name, icon, callback_url, client_type) + VALUES ($1, $2, $2, 'test-565-nulltype', '', 'http://localhost/callback', NULL) + `, nullTypeID, now) + require.NoError(t, err) + + migrationSQL, err := os.ReadFile("000565_oauth2_client_type_constraint.up.sql") + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + var backfilled string + err = sqlDB.QueryRowContext(ctx, + `SELECT client_type FROM oauth2_provider_apps WHERE id = $1`, nullTypeID, + ).Scan(&backfilled) + require.NoError(t, err) + require.Equal(t, "confidential", backfilled, + "a NULL client_type must read as confidential, the direction that keeps requiring a secret") + + var isNullable string + err = sqlDB.QueryRowContext(ctx, ` + SELECT is_nullable FROM information_schema.columns + WHERE table_name = 'oauth2_provider_apps' AND column_name = 'client_type' + `).Scan(&isNullable) + require.NoError(t, err) + require.Equal(t, "NO", isNullable, "client_type should be NOT NULL after the migration") + + // The CHECK is the point of the migration: prove it rejects rather than + // trusting that it exists. + for _, badValue := range []string{"Public", "PUBLIC", "public ", "bogus", ""} { + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO oauth2_provider_apps + (id, created_at, updated_at, name, icon, callback_url, client_type) + VALUES ($1, $2, $2, $3, '', 'http://localhost/callback', $4) + `, uuid.New(), now, "test-565-bad-"+badValue, badValue) + require.Error(t, err, "client_type %q must be rejected by the CHECK constraint", badValue) + require.ErrorContains(t, err, "oauth2_provider_apps_client_type_check") + } + + // A NULL is now rejected too, which is what makes IsPublic's fail-closed + // reading of an unset column unreachable rather than merely unused. + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO oauth2_provider_apps + (id, created_at, updated_at, name, icon, callback_url, client_type) + VALUES ($1, $2, $2, 'test-565-null-after', '', 'http://localhost/callback', NULL) + `, uuid.New(), now) + require.Error(t, err) + + // Both canonical values must still be insertable. + for _, goodValue := range []string{"confidential", "public"} { + _, err = sqlDB.ExecContext(ctx, ` + INSERT INTO oauth2_provider_apps + (id, created_at, updated_at, name, icon, callback_url, client_type) + VALUES ($1, $2, $2, $3, '', 'http://localhost/callback', $4) + `, uuid.New(), now, "test-565-good-"+goodValue, goodValue) + require.NoError(t, err, "client_type %q must remain valid", goodValue) + } +} + +// TestMigration000566OAuth2AuthMethodBackfill covers the repair the backfill +// exists for, which the shared fixture does not reach: it seeds no +// token_endpoint_auth_method at all, so the '= none' branch, the one that fixes +// the actual bug, matches zero rows in CI. +// +// token_endpoint_auth_method is the client's own RFC 7591 declaration; +// client_type is the derived value the token endpoint enforces on. Where they +// contradict, the declaration is aligned to what is enforced, never the reverse: +// deriving enforcement from the declaration would reclassify a confidential +// client holding a real secret as public and stop requiring that secret. +func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { + t.Parallel() + + sqlDB, ctx, ids := setupMigration000565Apps(t) + + // 000566 runs after 000565, so apply both in order. + for _, name := range []string{ + "000565_oauth2_client_type_constraint.up.sql", + "000566_oauth2_auth_method_backfill.up.sql", + } { + migrationSQL, err := os.ReadFile(name) + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err, "applying %s", name) + } + + tests := []struct { + key string + wantMethod string + reason string + }{ + { + key: "legacy", + wantMethod: "client_secret_basic", + reason: "a confidential client declaring \"none\" is the bug this migration repairs", + }, + { + key: "nullMethod", + wantMethod: "client_secret_basic", + reason: "a confidential client with no declaration gets the RFC 7591 default", + }, + { + key: "publicMismatched", + wantMethod: "none", + reason: "a public client cannot declare a secret-based method", + }, + { + key: "confidentialBasic", + wantMethod: "client_secret_basic", + reason: "already consistent, must be left alone", + }, + { + key: "confidentialPost", + wantMethod: "client_secret_post", + reason: "client_secret_post is consistent with confidential and must not be flattened to basic", + }, + { + key: "publicNone", + wantMethod: "none", + reason: "already consistent, must be left alone", + }, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + t.Parallel() + + var gotMethod, gotClientType string + err := sqlDB.QueryRowContext(ctx, ` + SELECT token_endpoint_auth_method, client_type + FROM oauth2_provider_apps WHERE id = $1 + `, ids[tt.key]).Scan(&gotMethod, &gotClientType) + require.NoError(t, err) + require.Equal(t, tt.wantMethod, gotMethod, tt.reason) + }) + } + + // The invariant the migration establishes, stated directly: no row may + // declare "none" while being enforced as confidential, or vice versa. + var contradictions int + err := sqlDB.QueryRowContext(ctx, ` + SELECT count(*) FROM oauth2_provider_apps + WHERE (token_endpoint_auth_method = 'none') <> (client_type = 'public') + `).Scan(&contradictions) + require.NoError(t, err) + require.Zero(t, contradictions, + "after the backfill no row may declare an auth method that contradicts its enforced client type") + + // client_type is the enforced column and the backfill must not touch it. + var stillConfidential string + err = sqlDB.QueryRowContext(ctx, + `SELECT client_type FROM oauth2_provider_apps WHERE id = $1`, ids["legacy"], + ).Scan(&stillConfidential) + require.NoError(t, err) + require.Equal(t, "confidential", stillConfidential, + "the backfill aligns the declaration to what is enforced, so the enforced value must be unchanged") +} From f4b0c5f7ed01143025b7c643995bc189d1bce17c Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 6 Aug 2026 17:29:25 -0700 Subject: [PATCH 03/10] test(coderd/database/migrations): drop a reference to a symbol not in this tree Addresses part of CRF-6 on #27931. The comment named IsPublic, which is added in #27873 and greps to nothing here, so a reader during the window between the two PRs goes looking for a function that does not exist. Ten panel reviewers flagged it independently. States the property directly instead, which is true at this commit and stays true afterwards. The finding's other four instances describe enforcement that arrives with #27873 and are deliberately left: unlike a dangling symbol they are accurate statements about where the column is headed, and each becomes true when that PR lands. Co-Authored-By: Claude Opus 5 (1M context) --- coderd/database/migrations/migrate_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index a6938fd1c30..1d9d6ce5152 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2849,8 +2849,8 @@ func TestMigration000565OAuth2ClientTypeConstraint(t *testing.T) { require.ErrorContains(t, err, "oauth2_provider_apps_client_type_check") } - // A NULL is now rejected too, which is what makes IsPublic's fail-closed - // reading of an unset column unreachable rather than merely unused. + // A NULL is now rejected too, so a reader that treats an unset column as + // confidential can never encounter one. _, err = sqlDB.ExecContext(ctx, ` INSERT INTO oauth2_provider_apps (id, created_at, updated_at, name, icon, callback_url, client_type) From 3eff0a087bb5ba48a08d734c51b31036f9eddbb5 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 6 Aug 2026 17:59:20 -0700 Subject: [PATCH 04/10] fix(coderd/database/migrations): repair any invalid confidential auth method Addresses CRF-9 on #27931. The two UPDATEs were asymmetric: the public branch excluded the one valid value and repaired everything else, while the confidential branch enumerated the two bad shapes known today, so a confidential row holding '' or an unrecognized method passed through untouched. That row would then be invisible permanently, not just unrepaired. The cross-column constraint this is heading towards, (method = 'none') = (client_type = 'public'), evaluates false = false for it and passes, so nothing looks at it again while RFC 7592 GET keeps handing the client a declaration it cannot use. Both branches now name the values valid for their client type and repair everything else, which makes the migration idempotent against history it cannot inspect rather than against the one bug we know about. Reachability is unverified and stated as such in the comment: ApplyDefaults maps "" to client_secret_basic and Valid() rejects unknown methods, so such a row needs a write path predating those guards. The IS NULL arm is kept deliberately. NULL NOT IN (...) evaluates to NULL and WHERE admits only true, so the widening alone would have stopped repairing NULL rows, which the narrow predicate did handle. Both regressions are mutation-checked: restoring the narrow predicate fails the empty and unrecognized cases, and dropping the IS NULL arm fails the NULL case. Co-Authored-By: Claude Opus 5 (1M context) --- .../000566_oauth2_auth_method_backfill.up.sql | 13 ++++++++++++- coderd/database/migrations/migrate_test.go | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql index 6e35e6c5bba..d81d5bc00db 100644 --- a/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql @@ -17,10 +17,21 @@ -- enforcement from the declaration would reclassify every such client as public -- and stop requiring the secret it holds, which is a silent authentication -- downgrade. +-- Both branches name the values that are valid for their client type and repair +-- everything else, rather than enumerating the bad values known today. A +-- confidential row holding '' or an unrecognized method would otherwise survive +-- untouched, and it would also satisfy the cross-column constraint this is +-- heading towards, since ('' = 'none') and (client_type = 'public') are both +-- false. Nothing would ever look at it again while RFC 7592 GET kept handing +-- the client a declaration it cannot use. +-- +-- The IS NULL arm is not redundant: NULL NOT IN (...) evaluates to NULL, and +-- WHERE admits only true, so without it NULL rows would stop being repaired. UPDATE oauth2_provider_apps SET token_endpoint_auth_method = 'client_secret_basic' -- the RFC 7591 section 2 default for a client with a secret WHERE client_type = 'confidential' - AND (token_endpoint_auth_method IS NULL OR token_endpoint_auth_method = 'none'); + AND (token_endpoint_auth_method IS NULL + OR token_endpoint_auth_method NOT IN ('client_secret_basic', 'client_secret_post')); -- The mirror case is not reachable through any current code path, since a public -- client is only ever created by requesting 'none'. Included so the invariant diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 1d9d6ce5152..96969559f6f 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2772,6 +2772,8 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin "confidentialBasic": uuid.New(), // already consistent -> must be left alone "confidentialPost": uuid.New(), // already consistent, and post must not be flattened to basic "publicNone": uuid.New(), // already consistent -> must be left alone + "confidentialEmpty": uuid.New(), // confidential + '' -> repaired only by the widened predicate + "confidentialJunk": uuid.New(), // confidential + unknown -> repaired only by the widened predicate } seed := func(id uuid.UUID, name, clientType string, authMethod *string) { @@ -2791,6 +2793,12 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin seed(ids["confidentialBasic"], "test-565-basic", "confidential", strPtr("client_secret_basic")) seed(ids["confidentialPost"], "test-565-post", "confidential", strPtr("client_secret_post")) seed(ids["publicNone"], "test-565-public", "public", strPtr("none")) + // Neither of these is producible by today's write path: ApplyDefaults maps + // "" to client_secret_basic and Valid() rejects unknown methods. They stand + // in for history the squashed log cannot rule out, and both would satisfy + // the eventual cross-column constraint while remaining unusable. + seed(ids["confidentialEmpty"], "test-565-empty", "confidential", strPtr("")) + seed(ids["confidentialJunk"], "test-565-junk", "confidential", strPtr("client_secret_jwt")) return sqlDB, ctx, ids } @@ -2930,6 +2938,16 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { wantMethod: "none", reason: "already consistent, must be left alone", }, + { + key: "confidentialEmpty", + wantMethod: "client_secret_basic", + reason: "an empty declaration is not a valid confidential method and must be repaired, not just the known 'none' case", + }, + { + key: "confidentialJunk", + wantMethod: "client_secret_basic", + reason: "an unrecognized declaration must be repaired too, so the migration is idempotent against history it cannot inspect", + }, } for _, tt := range tests { From c28798d027b299e13b25b9a702fcb2f1b033eb8c Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 6 Aug 2026 20:37:10 -0700 Subject: [PATCH 05/10] test(coderd/database/migrations): fix subtest deadline and NULL-blind assertions Addresses CRF-7 and CRF-5 on #27931. CRF-7: the parallel subtests queried through the parent's context. Its deadline starts when it is created, but a parallel subtest does not run until a -parallel slot frees, so most of the budget can be spent queued behind other tests in the package before the first query runs. The reported failure would be "context deadline exceeded" on a sub-20ms single-row read, naming no code and worsening as the package grows. paralleltestctx does not catch it because the context arrives through a helper's return value rather than a direct call in the subtest. Each subtest now creates its own. CRF-5, first half: no public + NULL row was seeded, so the second UPDATE's IS NULL arm matched nothing and removing it left the test green. That shape is now seeded and asserted. CRF-5, second half: the closing invariant used <>, which is NULL-blind. With a NULL declaration the comparison is NULL, WHERE drops the row, and an unrepaired NULL counts as consistent. Now IS DISTINCT FROM. This matters past this test: the predicate is the obvious candidate for the permanent cross-column CHECK after #27873, and a CHECK is more forgiving still, since NULL reads as not-violated. Mutation-checked. Removing the second UPDATE's IS NULL arm now fails both the publicNull case and the invariant count; with the old <> form only the former fires, which is what made the blind spot invisible. Full package passes at -parallel=1, the worst case for queue wait. Co-Authored-By: Claude Opus 5 (1M context) --- coderd/database/migrations/migrate_test.go | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 96969559f6f..249f9395589 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2774,6 +2774,7 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin "publicNone": uuid.New(), // already consistent -> must be left alone "confidentialEmpty": uuid.New(), // confidential + '' -> repaired only by the widened predicate "confidentialJunk": uuid.New(), // confidential + unknown -> repaired only by the widened predicate + "publicNull": uuid.New(), // public + NULL -> the second UPDATE's IS NULL arm } seed := func(id uuid.UUID, name, clientType string, authMethod *string) { @@ -2799,6 +2800,7 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin // the eventual cross-column constraint while remaining unusable. seed(ids["confidentialEmpty"], "test-565-empty", "confidential", strPtr("")) seed(ids["confidentialJunk"], "test-565-junk", "confidential", strPtr("client_secret_jwt")) + seed(ids["publicNull"], "test-565-public-null", "public", nil) return sqlDB, ctx, ids } @@ -2948,12 +2950,25 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { wantMethod: "client_secret_basic", reason: "an unrecognized declaration must be repaired too, so the migration is idempotent against history it cannot inspect", }, + { + key: "publicNull", + wantMethod: "none", + reason: "exercises the second UPDATE's IS NULL arm, which no other seeded row reaches", + }, } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { t.Parallel() + // A parallel subtest's own context, not the parent's. The parent's + // deadline starts when it is created, but a parallel subtest does + // not run until a -parallel slot frees, so it can spend most of the + // budget queued behind other tests in the package and then fail with + // "context deadline exceeded" on a sub-20ms read. That failure names + // no code and gets worse as the package grows. + ctx := testutil.Context(t, testutil.WaitLong) + var gotMethod, gotClientType string err := sqlDB.QueryRowContext(ctx, ` SELECT token_endpoint_auth_method, client_type @@ -2966,10 +2981,16 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { // The invariant the migration establishes, stated directly: no row may // declare "none" while being enforced as confidential, or vice versa. + // IS DISTINCT FROM, not <>. With <> a NULL declaration makes the whole + // comparison NULL, WHERE drops the row, and an unrepaired NULL is counted as + // consistent. That matters beyond this assertion: this predicate is the + // obvious candidate for the permanent cross-column CHECK after #27873, and a + // CHECK is more forgiving still, since NULL reads as not-violated. Carrying + // the <> form forward would carry the blind spot into a migration gate. var contradictions int err := sqlDB.QueryRowContext(ctx, ` SELECT count(*) FROM oauth2_provider_apps - WHERE (token_endpoint_auth_method = 'none') <> (client_type = 'public') + WHERE (token_endpoint_auth_method = 'none') IS DISTINCT FROM (client_type = 'public') `).Scan(&contradictions) require.NoError(t, err) require.Zero(t, contradictions, From 1a54ff7e3fce1936d8a518e6f6de958e82b38a8d Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 6 Aug 2026 20:44:07 -0700 Subject: [PATCH 06/10] refactor(coderd/database/migrations): address review nits on the client type migrations Addresses CRF-4 and CRF-10 through CRF-16 on #27931. CRF-11: the CHECK-violation assertion matched the constraint name as a raw string, while this migration generates a typed constant for it. Now uses database.IsCheckViolation with CheckOauth2ProviderAppsClientTypeCheck, the established idiom, which matches the pq error code and constraint name rather than error text, and which make gen keeps correct through a rename. CRF-13: the stepper loop treated exhaustion as success, so a renumbered or squashed 564 would silently apply 000565 and 000566 during setup and fail later on a confusing "constraint already exists". Now t.Fatalf, matching every other stepper loop in the file. CRF-14: the NULL-insert probe asserted only that some error occurred, while its siblings pin the constraint. Now pins the not-null violation. CRF-15: the down migration opened with "Deliberately empty." and then ran SELECT 1. Comment-only down migrations run fine (000506 is the precedent), so the statement is gone and the first line is true. CRF-12: the helper doc claimed it seeds every combination the two migrations care about, but 000565's NULL client_type row is seeded in that test. Narrowed to what it actually covers. CRF-10: the declaration-versus-enforcement rationale was written four times and 000565's twice. Kept the migration headers as the owning location and removed the copies that restated an adjacent require message, map literal, or header. Also folded in CRF-17's deploy-blocking consequence, which was the one thing the 000565 header did not say. CRF-16: "section 2" throughout, the majority form in the diff. CRF-4: the local strPtr closure replaced with the shared ptr.Ref. Re-verified after the assertion changes: dropping the CHECK still fails the constraint test, and the full package passes at -parallel=1. Co-Authored-By: Claude Opus 5 (1M context) --- ...00565_oauth2_client_type_constraint.up.sql | 12 ++-- ...00566_oauth2_auth_method_backfill.down.sql | 16 ++--- .../000566_oauth2_auth_method_backfill.up.sql | 6 +- coderd/database/migrations/migrate_test.go | 63 +++++++++---------- 4 files changed, 46 insertions(+), 51 deletions(-) diff --git a/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql b/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql index e6ae45e0858..e59dd1d054d 100644 --- a/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql +++ b/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql @@ -1,9 +1,11 @@ -- client_type decides whether the token endpoint validates a client secret at --- all, so a row holding an unrecognized value is a client whose authentication --- rules are undefined. Constrain it at the schema level: no Go path can write a --- bad value today, but a future migration writing 'public' onto a row that --- holds a secret would turn off client authentication for that app with nothing --- to catch it. +-- all. Constrain it at the schema level: no Go path can write a bad value +-- today, but a future migration writing 'public' onto a row that holds a secret +-- would turn off client authentication for that app with nothing to catch it. +-- +-- A non-NULL value outside the two canonical strings fails ADD CONSTRAINT and, +-- because all migrations share one transaction, blocks the upgrade. That is +-- deliberate: coercing an unexplained value would hide whatever wrote it. -- -- This should touch zero rows: migration 000344 added the column with a default -- of 'confidential' and backfilled existing rows with COALESCE. diff --git a/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql b/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql index 6f1e376fc45..0a957624794 100644 --- a/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql @@ -1,11 +1,7 @@ --- Deliberately empty. +-- Deliberately a no-op. -- --- The up migration repairs rows whose token_endpoint_auth_method contradicted --- the client_type the token endpoint enforces on. It does not record which rows --- it touched, so the previous values cannot be restored, and restoring them --- would only reinstate metadata that tells a client to authenticate in a way the --- server rejects. --- --- The schema is unchanged either way, so rolling back past this migration needs --- no structural work. -SELECT 1; +-- The up migration does not record which rows it touched, so the previous +-- values cannot be restored, and restoring them would only reinstate metadata +-- that tells a client to authenticate in a way the server rejects. The schema +-- is unchanged either way, so rolling back past this migration needs no +-- structural work. diff --git a/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql index d81d5bc00db..5b69bd1091b 100644 --- a/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql @@ -1,11 +1,9 @@ --- Two columns describe how an OAuth2 client authenticates, and they can --- currently contradict each other. --- -- token_endpoint_auth_method is the client's own declaration, registered client -- metadata under RFC 7591 section 2, where "none" is defined to mean the client -- is public and has no secret. client_type is Coder's derived copy of that same -- fact, and it is what the token endpoint actually enforces on. RFC 7591 defines --- no client_type metadata field; the column exists only as a denormalization. +-- no client_type metadata field, so the column is a denormalization that can +-- contradict its source. -- -- Registration used to persist the declaration verbatim while hardcoding -- client_type to 'confidential', so rows exist saying "auth method: none" on a diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 249f9395589..ef04e91b2a7 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/testutil" ) @@ -1669,7 +1670,10 @@ func TestMigration000546ChatHistoryAPIKeyConstraints(t *testing.T) { for { version, more, err := next() require.NoError(t, err) - if !more || version == priorMigrationVersion { + if !more { + t.Fatalf("migration %d not found", priorMigrationVersion) + } + if version == priorMigrationVersion { break } } @@ -2741,8 +2745,10 @@ func TestMigration000562OAuth2PublicClientTokensBackfill(t *testing.T) { } // setupMigration000565Apps steps to the migration just before 000565 and seeds -// oauth2_provider_apps rows covering every combination the two migrations care -// about. Returns the app IDs keyed by the shape they were seeded with. +// one oauth2_provider_apps row per token_endpoint_auth_method shape that 000566 +// repairs or preserves. 000565's NULL client_type case is seeded in its own +// test, since it is about the column this helper always populates. Returns the +// app IDs keyed by the shape they were seeded with. func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[string]uuid.UUID) { t.Helper() @@ -2754,7 +2760,10 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin for { version, more, err := next() require.NoError(t, err) - if !more || version == priorMigrationVersion { + if !more { + t.Fatalf("migration %d not found", priorMigrationVersion) + } + if version == priorMigrationVersion { break } } @@ -2762,9 +2771,9 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin ctx := testutil.Context(t, testutil.WaitSuperLong) now := time.Now().UTC().Truncate(time.Microsecond) - // The shapes that matter. "legacy" is the row the known bug produced: the - // client asked for "none", which RFC 7591 §2 defines as public, but - // client_type was hardcoded confidential and a secret was issued anyway. + // "legacy" is the row the known bug produced: the client asked for "none", + // which RFC 7591 section 2 defines as public, but client_type was hardcoded + // confidential and a secret was issued anyway. ids := map[string]uuid.UUID{ "legacy": uuid.New(), // confidential + none -> auth method must be repaired "nullMethod": uuid.New(), // confidential + NULL -> auth method must be repaired @@ -2786,20 +2795,18 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin `, id, now, name, clientType, authMethod) require.NoError(t, err) } - strPtr := func(s string) *string { return &s } - - seed(ids["legacy"], "test-565-legacy", "confidential", strPtr("none")) + seed(ids["legacy"], "test-565-legacy", "confidential", ptr.Ref("none")) seed(ids["nullMethod"], "test-565-null", "confidential", nil) - seed(ids["publicMismatched"], "test-565-public-mismatch", "public", strPtr("client_secret_basic")) - seed(ids["confidentialBasic"], "test-565-basic", "confidential", strPtr("client_secret_basic")) - seed(ids["confidentialPost"], "test-565-post", "confidential", strPtr("client_secret_post")) - seed(ids["publicNone"], "test-565-public", "public", strPtr("none")) + seed(ids["publicMismatched"], "test-565-public-mismatch", "public", ptr.Ref("client_secret_basic")) + seed(ids["confidentialBasic"], "test-565-basic", "confidential", ptr.Ref("client_secret_basic")) + seed(ids["confidentialPost"], "test-565-post", "confidential", ptr.Ref("client_secret_post")) + seed(ids["publicNone"], "test-565-public", "public", ptr.Ref("none")) // Neither of these is producible by today's write path: ApplyDefaults maps // "" to client_secret_basic and Valid() rejects unknown methods. They stand // in for history the squashed log cannot rule out, and both would satisfy // the eventual cross-column constraint while remaining unusable. - seed(ids["confidentialEmpty"], "test-565-empty", "confidential", strPtr("")) - seed(ids["confidentialJunk"], "test-565-junk", "confidential", strPtr("client_secret_jwt")) + seed(ids["confidentialEmpty"], "test-565-empty", "confidential", ptr.Ref("")) + seed(ids["confidentialJunk"], "test-565-junk", "confidential", ptr.Ref("client_secret_jwt")) seed(ids["publicNull"], "test-565-public-null", "public", nil) return sqlDB, ctx, ids @@ -2807,9 +2814,7 @@ func setupMigration000565Apps(t *testing.T) (*sql.DB, context.Context, map[strin // TestMigration000565OAuth2ClientTypeConstraint covers the constraint migration: // a NULL client_type is backfilled, the column becomes NOT NULL, and the CHECK -// rejects any value other than the two canonical ones. client_type is what the -// token endpoint reads to decide whether a client secret is validated at all, so -// the point of the constraint is that a value outside that set cannot exist. +// rejects any value other than the two canonical ones. func TestMigration000565OAuth2ClientTypeConstraint(t *testing.T) { t.Parallel() @@ -2847,8 +2852,7 @@ func TestMigration000565OAuth2ClientTypeConstraint(t *testing.T) { require.NoError(t, err) require.Equal(t, "NO", isNullable, "client_type should be NOT NULL after the migration") - // The CHECK is the point of the migration: prove it rejects rather than - // trusting that it exists. + // Prove the CHECK rejects rather than trusting that it exists. for _, badValue := range []string{"Public", "PUBLIC", "public ", "bogus", ""} { _, err = sqlDB.ExecContext(ctx, ` INSERT INTO oauth2_provider_apps @@ -2856,17 +2860,16 @@ func TestMigration000565OAuth2ClientTypeConstraint(t *testing.T) { VALUES ($1, $2, $2, $3, '', 'http://localhost/callback', $4) `, uuid.New(), now, "test-565-bad-"+badValue, badValue) require.Error(t, err, "client_type %q must be rejected by the CHECK constraint", badValue) - require.ErrorContains(t, err, "oauth2_provider_apps_client_type_check") + require.True(t, database.IsCheckViolation(err, database.CheckOauth2ProviderAppsClientTypeCheck), + "client_type %q must fail the check constraint specifically", badValue) } - // A NULL is now rejected too, so a reader that treats an unset column as - // confidential can never encounter one. _, err = sqlDB.ExecContext(ctx, ` INSERT INTO oauth2_provider_apps (id, created_at, updated_at, name, icon, callback_url, client_type) VALUES ($1, $2, $2, 'test-565-null-after', '', 'http://localhost/callback', NULL) `, uuid.New(), now) - require.Error(t, err) + require.ErrorContains(t, err, "not-null") // Both canonical values must still be insertable. for _, goodValue := range []string{"confidential", "public"} { @@ -2981,12 +2984,9 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { // The invariant the migration establishes, stated directly: no row may // declare "none" while being enforced as confidential, or vice versa. - // IS DISTINCT FROM, not <>. With <> a NULL declaration makes the whole - // comparison NULL, WHERE drops the row, and an unrepaired NULL is counted as - // consistent. That matters beyond this assertion: this predicate is the - // obvious candidate for the permanent cross-column CHECK after #27873, and a - // CHECK is more forgiving still, since NULL reads as not-violated. Carrying - // the <> form forward would carry the blind spot into a migration gate. + // IS DISTINCT FROM, not <>: with <> a NULL declaration makes the comparison + // NULL and WHERE drops the row, so an unrepaired NULL would count as + // consistent. The same trap awaits the permanent cross-column CHECK. var contradictions int err := sqlDB.QueryRowContext(ctx, ` SELECT count(*) FROM oauth2_provider_apps @@ -2996,7 +2996,6 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { require.Zero(t, contradictions, "after the backfill no row may declare an auth method that contradicts its enforced client type") - // client_type is the enforced column and the backfill must not touch it. var stillConfidential string err = sqlDB.QueryRowContext(ctx, `SELECT client_type FROM oauth2_provider_apps WHERE id = $1`, ids["legacy"], From de359a6940ee5ebda8f7feca885cc22b7cf1b30d Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 9 Aug 2026 08:55:08 -0700 Subject: [PATCH 07/10] refactor(coderd/database/migrations): finish the CRF-10 comment pass Three of the ten cited sites survived 1a54ff7e3f. migrate_test.go:2874: "Both canonical values must still be insertable." restated the loop and its require message, which is the canonical copy because it prints on failure. Deleted. TestMigration000566's doc: paragraph two repeated the 000566 header verbatim, so it is gone. Paragraph one said "the shared fixture", which read as setupMigration000565Apps, called on the next line and seeding token_endpoint_auth_method on every row, making the sentence false. The intended referent is the testdata/fixtures run, whose only oauth2_provider_apps row (000182) sets no auth method. Named directly. The invariant assertion's comment opened by restating its own require message. Removed those two lines; the IS DISTINCT FROM rationale is the part that is not derivable from the code and it stands alone. TestMigration000565 and TestMigration000566 pass; go vet clean. Co-Authored-By: Claude Opus 5 (1M context) --- coderd/database/migrations/migrate_test.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index ef04e91b2a7..3fcb5973e33 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2871,7 +2871,6 @@ func TestMigration000565OAuth2ClientTypeConstraint(t *testing.T) { `, uuid.New(), now) require.ErrorContains(t, err, "not-null") - // Both canonical values must still be insertable. for _, goodValue := range []string{"confidential", "public"} { _, err = sqlDB.ExecContext(ctx, ` INSERT INTO oauth2_provider_apps @@ -2883,15 +2882,9 @@ func TestMigration000565OAuth2ClientTypeConstraint(t *testing.T) { } // TestMigration000566OAuth2AuthMethodBackfill covers the repair the backfill -// exists for, which the shared fixture does not reach: it seeds no -// token_endpoint_auth_method at all, so the '= none' branch, the one that fixes -// the actual bug, matches zero rows in CI. -// -// token_endpoint_auth_method is the client's own RFC 7591 declaration; -// client_type is the derived value the token endpoint enforces on. Where they -// contradict, the declaration is aligned to what is enforced, never the reverse: -// deriving enforcement from the declaration would reclassify a confidential -// client holding a real secret as public and stop requiring that secret. +// exists for, which the testdata/fixtures run does not reach: its only +// oauth2_provider_apps row seeds no token_endpoint_auth_method at all, so the +// '= none' branch, the one that fixes the actual bug, matches zero rows in CI. func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { t.Parallel() @@ -2982,8 +2975,6 @@ func TestMigration000566OAuth2AuthMethodBackfill(t *testing.T) { }) } - // The invariant the migration establishes, stated directly: no row may - // declare "none" while being enforced as confidential, or vice versa. // IS DISTINCT FROM, not <>: with <> a NULL declaration makes the comparison // NULL and WHERE drops the row, so an unrepaired NULL would count as // consistent. The same trap awaits the permanent cross-column CHECK. From b254294cd1c5f5885c69f545cb871ad2e80c7aa8 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 9 Aug 2026 09:45:49 -0700 Subject: [PATCH 08/10] test(coderd/database/migrations): revert an out-of-scope stepper loop change CRF-13 asked for the stepper loop in setupMigration000565Apps to treat exhaustion as fatal. An earlier pass applied the same fix to TestMigration000546ChatHistoryAPIKeyConstraints, which predates this branch and has nothing to do with the OAuth2 client type work. The convention gap there is real, but it belongs in its own change rather than widening this diff into an unrelated test. migrate_test.go now differs from main only by the ptr import and the new 000565/000566 coverage. TestMigration000546, TestMigration000565 and TestMigration000566 pass. --- coderd/database/migrations/migrate_test.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 3fcb5973e33..df0c7d14ea9 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1670,10 +1670,7 @@ func TestMigration000546ChatHistoryAPIKeyConstraints(t *testing.T) { for { version, more, err := next() require.NoError(t, err) - if !more { - t.Fatalf("migration %d not found", priorMigrationVersion) - } - if version == priorMigrationVersion { + if !more || version == priorMigrationVersion { break } } From e983ec46f72bf80996bd277e7b81394f32c4c5bc Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sun, 9 Aug 2026 10:12:30 -0700 Subject: [PATCH 09/10] test(coderd/database/dbauthz): give InsertOAuth2ProviderApp a valid client_type The subtest passed a zero-value InsertOAuth2ProviderAppParams. That was fine while client_type was sql.NullString, where the zero value inserts NULL into a nullable column. This branch makes the column NOT NULL with a CHECK for 'confidential' and 'public', and the generated insert always names the column, so the DEFAULT never applies and the zero value now inserts '' and fails the constraint: pq: new row for relation "oauth2_provider_apps" violates check constraint "oauth2_provider_apps_client_type_check" Seeds 'confidential', the value every production call site already passes. The subtest asserts RBAC, not column contents, so this does not change what it verifies. Fixes test-go-pg, test-go-pg-17 and test-go-race-pg on b254294cd1, which all failed on this one subtest. --- coderd/database/dbauthz/dbauthz_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index c0cd5e280b8..82c1dd47e1f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5822,7 +5822,11 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { }) })) s.Run("InsertOAuth2ProviderApp", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertOAuth2ProviderAppParams{}).Asserts(rbac.ResourceOauth2App, policy.ActionCreate) + // client_type is NOT NULL with a CHECK for the two canonical values, and + // the insert always sends the column, so the zero value cannot be used. + check.Args(database.InsertOAuth2ProviderAppParams{ + ClientType: "confidential", + }).Asserts(rbac.ResourceOauth2App, policy.ActionCreate) })) s.Run("UpdateOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) From 6bca0ee80c683db265e2cce949dccff0e6c4844a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 10 Aug 2026 10:11:16 -0700 Subject: [PATCH 10/10] docs(coderd/database/migrations): trim the auth method backfill comment --- .../000566_oauth2_auth_method_backfill.up.sql | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql index 5b69bd1091b..4b9505bd3e3 100644 --- a/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql @@ -1,30 +1,21 @@ --- token_endpoint_auth_method is the client's own declaration, registered client --- metadata under RFC 7591 section 2, where "none" is defined to mean the client --- is public and has no secret. client_type is Coder's derived copy of that same --- fact, and it is what the token endpoint actually enforces on. RFC 7591 defines --- no client_type metadata field, so the column is a denormalization that can --- contradict its source. +-- token_endpoint_auth_method is the client's declared auth method (RFC 7591 +-- section 2); client_type is Coder's own field, and it is what the token +-- endpoint actually enforces. The two can disagree: registration used to +-- persist a client's "none" declaration while hardcoding client_type to +-- confidential, so some clients declare themselves public while still +-- holding, and needing, a real secret. -- --- Registration used to persist the declaration verbatim while hardcoding --- client_type to 'confidential', so rows exist saying "auth method: none" on a --- client stored confidential that was issued, and still requires, a real secret. --- A client that reads its own metadata and believes it is public will drop that --- secret and stop being able to exchange codes. +-- Align the declaration to what is enforced, not the reverse: deriving +-- enforcement from the declaration would silently stop requiring the secret +-- those clients already hold. -- --- Align the declaration to what is enforced, not the reverse. Deriving --- enforcement from the declaration would reclassify every such client as public --- and stop requiring the secret it holds, which is a silent authentication --- downgrade. --- Both branches name the values that are valid for their client type and repair --- everything else, rather than enumerating the bad values known today. A --- confidential row holding '' or an unrecognized method would otherwise survive --- untouched, and it would also satisfy the cross-column constraint this is --- heading towards, since ('' = 'none') and (client_type = 'public') are both --- false. Nothing would ever look at it again while RFC 7592 GET kept handing --- the client a declaration it cannot use. +-- Each branch repairs anything outside the valid set for its client_type, +-- not just the one bad value seen so far, so a stray '' or unrecognized +-- method can't slip through and later pass a client_type/auth_method +-- consistency check unnoticed. -- --- The IS NULL arm is not redundant: NULL NOT IN (...) evaluates to NULL, and --- WHERE admits only true, so without it NULL rows would stop being repaired. +-- The IS NULL arm matters: NULL NOT IN (...) evaluates to NULL, and WHERE +-- only admits true, so without it NULL rows would stop being repaired. UPDATE oauth2_provider_apps SET token_endpoint_auth_method = 'client_secret_basic' -- the RFC 7591 section 2 default for a client with a secret WHERE client_type = 'confidential'