feat: constrain the OAuth2 client type column (#27931)

Extracted from #27873 so the schema change can be reviewed for migration
safety on its own. #27873 will rebase onto this.

`client_type` decides whether the token endpoint validates a client
secret at all, and the column accepts any text: nullable, no `CHECK`, no
enum. No Go path can write a bad value today, and `IsPublic` fails
closed on anything unrecognized, so the read side is safe. What the
schema still permits is the problem: 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.

`000565` adds `CHECK (client_type IN ('confidential', 'public'))` and
`NOT NULL`. The `UPDATE` ahead of it should touch zero rows, since
migration `000344` added the column with a default of `'confidential'`
and backfilled with `COALESCE`; it is there so `SET NOT NULL` cannot
fail on an unexpected row. Both `ALTER`s take `ACCESS EXCLUSIVE` and
scan a table holding one row per registered OAuth2 client, so the lock
is brief.

## The second migration, and why it aligns the way it does

Two columns describe the same fact and can currently contradict each
other.

`token_endpoint_auth_method` is the client's own declaration: registered
client metadata under RFC 7591 §2, where `"none"` is defined to mean the
client is public and has no secret. `client_type` is Coder's derived
copy, and it is what the token endpoint 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 declaring `"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.

`000566` aligns 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. The down migration is deliberately
empty: the previous values are not recorded, and restoring them would
only reinstate metadata that tells a client to authenticate in a way the
server rejects.

## Application changes

`SET NOT NULL` changes the generated field from `sql.NullString` to
`string`, so the three write sites are updated to match. That is the
entire application diff and no behavior depends on it.

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bobby Ho
2026-08-10 10:49:02 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 2f34e1abd0
commit 16c58770f8
13 changed files with 332 additions and 11 deletions
+1
View File
@@ -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
+5 -1
View File
@@ -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)
+1 -1
View File
@@ -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{}),
+3 -2
View File
@@ -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.';
@@ -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;
@@ -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;
@@ -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.
@@ -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');
+253
View File
@@ -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")
}
+1 -1
View File
@@ -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
+3 -3
View File
@@ -19246,7 +19246,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"`
@@ -19555,7 +19555,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"`
@@ -19661,7 +19661,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"`
+1 -1
View File
@@ -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{},
+2 -2
View File
@@ -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),