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/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) 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..e59dd1d054d --- /dev/null +++ b/coderd/database/migrations/000565_oauth2_client_type_constraint.up.sql @@ -0,0 +1,19 @@ +-- client_type decides whether the token endpoint validates a client secret at +-- 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. +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..0a957624794 --- /dev/null +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.down.sql @@ -0,0 +1,7 @@ +-- Deliberately a no-op. +-- +-- 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 new file mode 100644 index 00000000000..4b9505bd3e3 --- /dev/null +++ b/coderd/database/migrations/000566_oauth2_auth_method_backfill.up.sql @@ -0,0 +1,31 @@ +-- 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. +-- +-- 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. +-- +-- 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 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' + 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 +-- 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/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index cc1beeea3a2..df0c7d14ea9 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" ) @@ -2739,3 +2740,255 @@ 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 +// 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() + + 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 { + t.Fatalf("migration %d not found", priorMigrationVersion) + } + if version == priorMigrationVersion { + break + } + } + + ctx := testutil.Context(t, testutil.WaitSuperLong) + now := time.Now().UTC().Truncate(time.Microsecond) + + // "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 + "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 + "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) { + 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) + } + 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", 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", 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 +} + +// 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. +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") + + // 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 + (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.True(t, database.IsCheckViolation(err, database.CheckOauth2ProviderAppsClientTypeCheck), + "client_type %q must fail the check constraint specifically", badValue) + } + + _, 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.ErrorContains(t, err, "not-null") + + 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 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() + + 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", + }, + { + 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", + }, + { + 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 + 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) + }) + } + + // 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 + WHERE (token_endpoint_auth_method = 'none') IS DISTINCT FROM (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") + + 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") +} 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 d79af1da687..e7fdc9c6dc9 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),