From 0853bb0e8378c3ef2079baa15ce1b23e8f44fc9a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 30 Jul 2026 16:46:22 -0700 Subject: [PATCH 1/3] feat(coderd/database): support public OAuth2 client tokens at the schema layer Add app_id to oauth2_provider_app_tokens and make app_secret_id nullable, so a public (secretless, PKCE-only) client's tokens can still be attributed to their owning app for revocation and listing, without joining through a secret that does not exist. This is a single, final migration: app_id is populated at insert time from day one, so there is no window where new rows are written with a NULL app_id, and no second backfill will ever be needed. Fixes a review finding from an earlier version of this branch: the migration previously only added app_id as an unenforced, unpopulated column, deferring app_secret_id's nullability and app_id's write path to a later PR. That left a gap where any token created between this PR merging and the next one landing would carry a NULL app_id permanently, defeating the column's purpose. Doing the full migration now, with the insert path updated in the same change, closes that gap entirely. This requires touching a few call sites outside coderd/database, but only mechanically: revoke.go's ownership checks now compare app_id directly instead of joining through app_secret_id (a genuine simplification, not a shim), and tokens.go's two insert call sites supply the new app_id column and wrap app_secret_id as a NullUUID. No client-type branching exists yet, no public client can be registered, and behavior for today's confidential-only clients is unchanged. That capability (registration skipping secret generation, the token endpoint accepting PKCE alone, discovery advertising "none") lands in a follow-up PR. Layer 1 of a multi-PR split of #27195 (public/secretless PKCE-only OAuth2 clients), broken up for easier review: database schema (this PR), then the oauth2provider handler logic, then API/e2e integration tests. Co-Authored-By: Claude Sonnet 5 --- coderd/database/dbauthz/dbauthz_test.go | 15 ++-- coderd/database/dbgen/dbgen.go | 3 +- coderd/database/dump.sql | 10 ++- coderd/database/foreign_key_constraint.go | 1 + ...00562_oauth2_public_client_tokens.down.sql | 8 ++ .../000562_oauth2_public_client_tokens.up.sql | 26 ++++++ coderd/database/migrations/migrate_test.go | 88 +++++++++++++++++++ coderd/database/models.go | 8 +- coderd/database/querier.go | 6 ++ coderd/database/queries.sql.go | 32 ++++--- coderd/database/queries/oauth2.sql | 19 ++-- coderd/oauth2_test.go | 3 +- coderd/oauth2provider/revoke.go | 21 ++--- coderd/oauth2provider/tokens.go | 4 +- 14 files changed, 196 insertions(+), 48 deletions(-) create mode 100644 coderd/database/migrations/000562_oauth2_public_client_tokens.down.sql create mode 100644 coderd/database/migrations/000562_oauth2_public_client_tokens.up.sql diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e4e4264ff65..cb7ff667e17 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6044,7 +6044,8 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { }) for i := 0; i < 5; i++ { _ = dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{ - AppSecretID: secret.ID, + AppID: app.ID, + AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true}, APIKeyID: key.ID, UserID: user.ID, HashPrefix: []byte(fmt.Sprintf("%d", i)), @@ -6252,7 +6253,8 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() { AppID: app.ID, }) check.Args(database.InsertOAuth2ProviderAppTokenParams{ - AppSecretID: secret.ID, + AppID: app.ID, + AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true}, APIKeyID: key.ID, UserID: user.ID, }).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionCreate) @@ -6267,7 +6269,8 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() { AppID: app.ID, }) token := dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{ - AppSecretID: secret.ID, + AppID: app.ID, + AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true}, APIKeyID: key.ID, UserID: user.ID, }) @@ -6283,7 +6286,8 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() { AppID: app.ID, }) token := dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{ - AppSecretID: secret.ID, + AppID: app.ID, + AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true}, APIKeyID: key.ID, UserID: user.ID, }) @@ -6301,7 +6305,8 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() { }) for i := 0; i < 5; i++ { _ = dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{ - AppSecretID: secret.ID, + AppID: app.ID, + AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true}, APIKeyID: key.ID, UserID: user.ID, HashPrefix: []byte(fmt.Sprintf("%d", i)), diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1c0cf593d44..82ea7ad1b0d 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1796,7 +1796,8 @@ func OAuth2ProviderAppToken(t testing.TB, db database.Store, seed database.OAuth ExpiresAt: takeFirst(seed.CreatedAt, dbtime.Now()), HashPrefix: takeFirstSlice(seed.HashPrefix, []byte("prefix")), RefreshHash: takeFirstSlice(seed.RefreshHash, []byte("hashed-secret")), - AppSecretID: takeFirst(seed.AppSecretID, uuid.New()), + AppID: takeFirst(seed.AppID, uuid.New()), + AppSecretID: takeFirst(seed.AppSecretID, uuid.NullUUID{UUID: uuid.New(), Valid: true}), APIKeyID: takeFirst(seed.APIKeyID, uuid.New().String()), UserID: takeFirst(seed.UserID, uuid.New()), Audience: seed.Audience, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b63f1193958..8af6db94bab 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2629,10 +2629,11 @@ CREATE TABLE oauth2_provider_app_tokens ( expires_at timestamp with time zone NOT NULL, hash_prefix bytea NOT NULL, refresh_hash bytea NOT NULL, - app_secret_id uuid NOT NULL, + app_secret_id uuid, api_key_id text NOT NULL, audience text, - user_id uuid NOT NULL + user_id uuid NOT NULL, + app_id uuid NOT NULL ); COMMENT ON COLUMN oauth2_provider_app_tokens.refresh_hash IS 'Refresh tokens provide a way to refresh an access token (API key). An expired API key can be refreshed if this token is not yet expired, meaning this expiry can outlive an API key.'; @@ -2641,6 +2642,8 @@ COMMENT ON COLUMN oauth2_provider_app_tokens.audience IS 'Token audience binding COMMENT ON COLUMN oauth2_provider_app_tokens.user_id IS 'Denormalized user ID for performance optimization in authorization checks'; +COMMENT ON COLUMN oauth2_provider_app_tokens.app_id IS 'Denormalized app ID so ownership checks (e.g. revocation) do not need to join through app_secret_id, which is NULL for public clients.'; + CREATE TABLE oauth2_provider_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -5312,6 +5315,9 @@ ALTER TABLE ONLY oauth2_provider_app_secrets ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE; +ALTER TABLE ONLY oauth2_provider_app_tokens + ADD CONSTRAINT oauth2_provider_app_tokens_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; + ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_secret_id_fkey FOREIGN KEY (app_secret_id) REFERENCES oauth2_provider_app_secrets(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 75c99671c63..a24e3d73b5c 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -73,6 +73,7 @@ const ( ForeignKeyOauth2ProviderAppCodesUserID ForeignKeyConstraint = "oauth2_provider_app_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppTokensAPIKeyID ForeignKeyConstraint = "oauth2_provider_app_tokens_api_key_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppTokensAppID ForeignKeyConstraint = "oauth2_provider_app_tokens_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppTokensAppSecretID ForeignKeyConstraint = "oauth2_provider_app_tokens_app_secret_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_secret_id_fkey FOREIGN KEY (app_secret_id) REFERENCES oauth2_provider_app_secrets(id) ON DELETE CASCADE; ForeignKeyOrganizationMembersOrganizationIDUUID ForeignKeyConstraint = "organization_members_organization_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyOrganizationMembersUserIDUUID ForeignKeyConstraint = "organization_members_user_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000562_oauth2_public_client_tokens.down.sql b/coderd/database/migrations/000562_oauth2_public_client_tokens.down.sql new file mode 100644 index 00000000000..ce09ba7e5b3 --- /dev/null +++ b/coderd/database/migrations/000562_oauth2_public_client_tokens.down.sql @@ -0,0 +1,8 @@ +-- Reverse of up-step 4: restore the original NOT NULL. Fails if any +-- public-client token (app_secret_id IS NULL) exists. Revoke every +-- outstanding public-client session before rolling this migration back. +ALTER TABLE oauth2_provider_app_tokens ALTER COLUMN app_secret_id SET NOT NULL; + +-- Reverse of up-step 3/1: drop the new column and its FK entirely. +ALTER TABLE oauth2_provider_app_tokens DROP CONSTRAINT oauth2_provider_app_tokens_app_id_fkey; +ALTER TABLE oauth2_provider_app_tokens DROP COLUMN app_id; diff --git a/coderd/database/migrations/000562_oauth2_public_client_tokens.up.sql b/coderd/database/migrations/000562_oauth2_public_client_tokens.up.sql new file mode 100644 index 00000000000..e985f828f03 --- /dev/null +++ b/coderd/database/migrations/000562_oauth2_public_client_tokens.up.sql @@ -0,0 +1,26 @@ +-- Public (secretless, PKCE-only) OAuth2 clients have no client_secret, so +-- their tokens have nothing to put in app_secret_id. Add a direct app_id +-- column so token ownership checks (e.g. revocation) don't have to join +-- through a secret that may not exist, then loosen app_secret_id's NOT NULL. + +-- Step 1: add app_id as nullable first. +ALTER TABLE oauth2_provider_app_tokens ADD COLUMN app_id uuid; + +-- Step 2: backfill every existing row via the only path available today +-- (the same join revoke.go currently does at request time). +UPDATE oauth2_provider_app_tokens t +SET app_id = s.app_id +FROM oauth2_provider_app_secrets s +WHERE t.app_secret_id = s.id; + +-- Step 3: now that every row has a value, constrain it. +ALTER TABLE oauth2_provider_app_tokens ALTER COLUMN app_id SET NOT NULL; +ALTER TABLE oauth2_provider_app_tokens + ADD CONSTRAINT oauth2_provider_app_tokens_app_id_fkey + FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; + +-- Step 4: only now loosen app_secret_id, since every row already has a +-- reliable app_id to fall back on before this runs. +ALTER TABLE oauth2_provider_app_tokens ALTER COLUMN app_secret_id DROP NOT NULL; + +COMMENT ON COLUMN oauth2_provider_app_tokens.app_id IS 'Denormalized app ID so ownership checks (e.g. revocation) do not need to join through app_secret_id, which is NULL for public clients.'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index def839c96f1..b508d5d1e94 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2413,3 +2413,91 @@ func TestMigration000556UserSecretsEnabled(t *testing.T) { "secret with both targets empty should be flipped to disabled "+ "to preserve the previous implicit-skip behavior") } + +// TestMigration000562OAuth2PublicClientTokensBackfill seeds a pre-migration +// oauth2_provider_app_tokens row (the only shape that could exist before this +// migration, since app_secret_id was NOT NULL) and asserts that the new app_id +// column is backfilled from the existing app_secret_id -> app_id join, and +// that app_secret_id becomes nullable afterward. +func TestMigration000562OAuth2PublicClientTokensBackfill(t *testing.T) { + t.Parallel() + + const priorMigrationVersion = 561 + + 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) + + userID := uuid.New() + appID := uuid.New() + secretID := uuid.New() + tokenID := uuid.New() + const apiKeyID = "test562apikeyid" + + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, ` + INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type) + VALUES ($1, 'test-user-562', 'test-562@example.com', ''::bytea, $2, $2, 'active', '{}', 'password') + `, userID, now) + require.NoError(t, err) + + _, err = tx.ExecContext(ctx, ` + INSERT INTO api_keys (id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, scopes, allow_list) + VALUES ($1, ''::bytea, $2, $3, $3, $3, $3, 'oauth2_provider_app', '{}', '{*}') + `, apiKeyID, userID, now) + require.NoError(t, err) + + _, err = tx.ExecContext(ctx, ` + INSERT INTO oauth2_provider_apps (id, created_at, updated_at, name, icon, callback_url) + VALUES ($1, $2, $2, 'test-app-562', '', 'http://localhost/callback') + `, appID, now) + require.NoError(t, err) + + _, err = tx.ExecContext(ctx, ` + INSERT INTO oauth2_provider_app_secrets (id, created_at, hashed_secret, display_secret, app_id, secret_prefix) + VALUES ($1, $2, ''::bytea, '****1234', $3, 'prefix562'::bytea) + `, secretID, now, appID) + require.NoError(t, err) + + _, err = tx.ExecContext(ctx, ` + INSERT INTO oauth2_provider_app_tokens (id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, user_id) + VALUES ($1, $2, $3, 'prefix562'::bytea, ''::bytea, $4, $5, $6) + `, tokenID, now, now.Add(time.Hour), secretID, apiKeyID, userID) + require.NoError(t, err) + + require.NoError(t, tx.Commit()) + + migrationSQL, err := os.ReadFile("000562_oauth2_public_client_tokens.up.sql") + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + var backfilledAppID uuid.UUID + err = sqlDB.QueryRowContext(ctx, + `SELECT app_id FROM oauth2_provider_app_tokens WHERE id = $1`, tokenID, + ).Scan(&backfilledAppID) + require.NoError(t, err) + require.Equal(t, appID, backfilledAppID, "app_id should be backfilled from app_secret_id's existing join") + + var isNullable string + err = sqlDB.QueryRowContext(ctx, ` + SELECT is_nullable FROM information_schema.columns + WHERE table_name = 'oauth2_provider_app_tokens' AND column_name = 'app_secret_id' + `).Scan(&isNullable) + require.NoError(t, err) + require.Equal(t, "YES", isNullable, "app_secret_id should be nullable after the migration") +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 4a979e94c8b..6be9b7d19c9 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5602,13 +5602,15 @@ type OAuth2ProviderAppToken struct { ExpiresAt time.Time `db:"expires_at" json:"expires_at"` HashPrefix []byte `db:"hash_prefix" json:"hash_prefix"` // Refresh tokens provide a way to refresh an access token (API key). An expired API key can be refreshed if this token is not yet expired, meaning this expiry can outlive an API key. - RefreshHash []byte `db:"refresh_hash" json:"refresh_hash"` - AppSecretID uuid.UUID `db:"app_secret_id" json:"app_secret_id"` - APIKeyID string `db:"api_key_id" json:"api_key_id"` + RefreshHash []byte `db:"refresh_hash" json:"refresh_hash"` + AppSecretID uuid.NullUUID `db:"app_secret_id" json:"app_secret_id"` + APIKeyID string `db:"api_key_id" json:"api_key_id"` // Token audience binding from resource parameter Audience sql.NullString `db:"audience" json:"audience"` // Denormalized user ID for performance optimization in authorization checks UserID uuid.UUID `db:"user_id" json:"user_id"` + // Denormalized app ID so ownership checks (e.g. revocation) do not need to join through app_secret_id, which is NULL for public clients. + AppID uuid.UUID `db:"app_id" json:"app_id"` } type Organization struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index db572c970db..f25f7a40ccf 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -171,6 +171,9 @@ type sqlcQuerier interface { DeleteOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) error DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppCodesByAppAndUserIDParams) error DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error + // Filters directly on app_id rather than joining through app_secret_id, + // since app_secret_id is NULL for public (secretless) clients and would + // silently exclude their tokens from this delete. DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppTokensByAppAndUserIDParams) error // Cumulative count. DeleteOldAIBridgeRecords(ctx context.Context, beforeTime time.Time) (int64, error) @@ -711,6 +714,9 @@ type sqlcQuerier interface { GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (OAuth2ProviderAppToken, error) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (OAuth2ProviderAppToken, error) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) + // Joins directly on oauth2_provider_app_tokens.app_id rather than through + // app_secret_id, since app_secret_id is NULL for public (secretless) clients + // and would silently exclude their tokens from this listing. GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e385038f618..00bee1b6ad1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -19572,11 +19572,8 @@ func (q *sqlQuerier) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id u const deleteOAuth2ProviderAppTokensByAppAndUserID = `-- name: DeleteOAuth2ProviderAppTokensByAppAndUserID :exec DELETE FROM oauth2_provider_app_tokens -USING - oauth2_provider_app_secrets WHERE - oauth2_provider_app_secrets.id = oauth2_provider_app_tokens.app_secret_id - AND oauth2_provider_app_secrets.app_id = $1 + oauth2_provider_app_tokens.app_id = $1 AND oauth2_provider_app_tokens.user_id = $2 ` @@ -19585,6 +19582,9 @@ type DeleteOAuth2ProviderAppTokensByAppAndUserIDParams struct { UserID uuid.UUID `db:"user_id" json:"user_id"` } +// Filters directly on app_id rather than joining through app_secret_id, +// since app_secret_id is NULL for public (secretless) clients and would +// silently exclude their tokens from this delete. func (q *sqlQuerier) DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Context, arg DeleteOAuth2ProviderAppTokensByAppAndUserIDParams) error { _, err := q.db.ExecContext(ctx, deleteOAuth2ProviderAppTokensByAppAndUserID, arg.AppID, arg.UserID) return err @@ -19790,7 +19790,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, app } const getOAuth2ProviderAppTokenByAPIKeyID = `-- name: GetOAuth2ProviderAppTokenByAPIKeyID :one -SELECT id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience, user_id FROM oauth2_provider_app_tokens WHERE api_key_id = $1 +SELECT id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience, user_id, app_id FROM oauth2_provider_app_tokens WHERE api_key_id = $1 ` func (q *sqlQuerier) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (OAuth2ProviderAppToken, error) { @@ -19806,12 +19806,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, ap &i.APIKeyID, &i.Audience, &i.UserID, + &i.AppID, ) return i, err } const getOAuth2ProviderAppTokenByPrefix = `-- name: GetOAuth2ProviderAppTokenByPrefix :one -SELECT id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience, user_id FROM oauth2_provider_app_tokens WHERE hash_prefix = $1 +SELECT id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience, user_id, app_id FROM oauth2_provider_app_tokens WHERE hash_prefix = $1 ` func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (OAuth2ProviderAppToken, error) { @@ -19827,6 +19828,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hash &i.APIKeyID, &i.Audience, &i.UserID, + &i.AppID, ) return i, err } @@ -19890,10 +19892,8 @@ SELECT COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count, oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.callback_url, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri FROM oauth2_provider_app_tokens - INNER JOIN oauth2_provider_app_secrets - ON oauth2_provider_app_secrets.id = oauth2_provider_app_tokens.app_secret_id INNER JOIN oauth2_provider_apps - ON oauth2_provider_apps.id = oauth2_provider_app_secrets.app_id + ON oauth2_provider_apps.id = oauth2_provider_app_tokens.app_id WHERE oauth2_provider_app_tokens.user_id = $1 GROUP BY @@ -19905,6 +19905,9 @@ type GetOAuth2ProviderAppsByUserIDRow struct { OAuth2ProviderApp OAuth2ProviderApp `db:"oauth2_provider_app" json:"oauth2_provider_app"` } +// Joins directly on oauth2_provider_app_tokens.app_id rather than through +// app_secret_id, since app_secret_id is NULL for public (secretless) clients +// and would silently exclude their tokens from this listing. func (q *sqlQuerier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error) { rows, err := q.db.QueryContext(ctx, getOAuth2ProviderAppsByUserID, userID) if err != nil { @@ -20238,6 +20241,7 @@ INSERT INTO oauth2_provider_app_tokens ( expires_at, hash_prefix, refresh_hash, + app_id, app_secret_id, api_key_id, user_id, @@ -20251,8 +20255,9 @@ INSERT INTO oauth2_provider_app_tokens ( $6, $7, $8, - $9 -) RETURNING id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience, user_id + $9, + $10 +) RETURNING id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience, user_id, app_id ` type InsertOAuth2ProviderAppTokenParams struct { @@ -20261,7 +20266,8 @@ type InsertOAuth2ProviderAppTokenParams struct { ExpiresAt time.Time `db:"expires_at" json:"expires_at"` HashPrefix []byte `db:"hash_prefix" json:"hash_prefix"` RefreshHash []byte `db:"refresh_hash" json:"refresh_hash"` - AppSecretID uuid.UUID `db:"app_secret_id" json:"app_secret_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + AppSecretID uuid.NullUUID `db:"app_secret_id" json:"app_secret_id"` APIKeyID string `db:"api_key_id" json:"api_key_id"` UserID uuid.UUID `db:"user_id" json:"user_id"` Audience sql.NullString `db:"audience" json:"audience"` @@ -20274,6 +20280,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppToken(ctx context.Context, arg Inser arg.ExpiresAt, arg.HashPrefix, arg.RefreshHash, + arg.AppID, arg.AppSecretID, arg.APIKeyID, arg.UserID, @@ -20290,6 +20297,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppToken(ctx context.Context, arg Inser &i.APIKeyID, &i.Audience, &i.UserID, + &i.AppID, ) return i, err } diff --git a/coderd/database/queries/oauth2.sql b/coderd/database/queries/oauth2.sql index e7162b5ab1a..f9272b69ea1 100644 --- a/coderd/database/queries/oauth2.sql +++ b/coderd/database/queries/oauth2.sql @@ -166,6 +166,7 @@ INSERT INTO oauth2_provider_app_tokens ( expires_at, hash_prefix, refresh_hash, + app_id, app_secret_id, api_key_id, user_id, @@ -179,7 +180,8 @@ INSERT INTO oauth2_provider_app_tokens ( $6, $7, $8, - $9 + $9, + $10 ) RETURNING *; -- name: GetOAuth2ProviderAppTokenByPrefix :one @@ -189,27 +191,28 @@ SELECT * FROM oauth2_provider_app_tokens WHERE hash_prefix = $1; SELECT * FROM oauth2_provider_app_tokens WHERE api_key_id = $1; -- name: GetOAuth2ProviderAppsByUserID :many +-- Joins directly on oauth2_provider_app_tokens.app_id rather than through +-- app_secret_id, since app_secret_id is NULL for public (secretless) clients +-- and would silently exclude their tokens from this listing. SELECT COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count, sqlc.embed(oauth2_provider_apps) FROM oauth2_provider_app_tokens - INNER JOIN oauth2_provider_app_secrets - ON oauth2_provider_app_secrets.id = oauth2_provider_app_tokens.app_secret_id INNER JOIN oauth2_provider_apps - ON oauth2_provider_apps.id = oauth2_provider_app_secrets.app_id + ON oauth2_provider_apps.id = oauth2_provider_app_tokens.app_id WHERE oauth2_provider_app_tokens.user_id = $1 GROUP BY oauth2_provider_apps.id; -- name: DeleteOAuth2ProviderAppTokensByAppAndUserID :exec +-- Filters directly on app_id rather than joining through app_secret_id, +-- since app_secret_id is NULL for public (secretless) clients and would +-- silently exclude their tokens from this delete. DELETE FROM oauth2_provider_app_tokens -USING - oauth2_provider_app_secrets WHERE - oauth2_provider_app_secrets.id = oauth2_provider_app_tokens.app_secret_id - AND oauth2_provider_app_secrets.app_id = $1 + oauth2_provider_app_tokens.app_id = $1 AND oauth2_provider_app_tokens.user_id = $2; -- RFC 7591/7592 Dynamic Client Registration queries diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 55491fcc21d..448e737e819 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -630,7 +630,8 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { ExpiresAt: expires, HashPrefix: []byte(token.Prefix), RefreshHash: token.Hashed, - AppSecretID: secret.ID, + AppID: test.app.ID, + AppSecretID: uuid.NullUUID{UUID: secret.ID, Valid: true}, APIKeyID: newKey.ID, UserID: user.ID, }) diff --git a/coderd/oauth2provider/revoke.go b/coderd/oauth2provider/revoke.go index 5893c674ec3..bba2757775d 100644 --- a/coderd/oauth2provider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -139,13 +139,9 @@ func revokeRefreshTokenInTx(ctx context.Context, db database.Store, token string return xerrors.Errorf("invalid refresh token") } - // Verify ownership - //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint - appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID) - if err != nil { - return xerrors.Errorf("get oauth2 provider app secret: %w", err) - } - if appSecret.AppID != appID { + // Verify ownership directly via app_id, avoiding a join through + // app_secret_id, which is not always present. + if dbToken.AppID != appID { return ErrTokenNotBelongsToClient } @@ -199,14 +195,9 @@ func revokeAPIKeyInTx(ctx context.Context, db database.Store, token string, appI return xerrors.Errorf("get oauth2 provider app token by api key id: %w", err) } - // Verify the token belongs to the requesting app - //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint - appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID) - if err != nil { - return xerrors.Errorf("get oauth2 provider app secret for api key verification: %w", err) - } - - if appSecret.AppID != appID { + // Verify the token belongs to the requesting app directly via app_id, + // avoiding a join through app_secret_id, which is not always present. + if dbToken.AppID != appID { return ErrTokenNotBelongsToClient } diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 638856d3e6e..0d00d05ec21 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -355,7 +355,8 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database ExpiresAt: refreshExpiresAt, HashPrefix: []byte(refreshToken.Prefix), RefreshHash: refreshToken.Hashed, - AppSecretID: dbSecret.ID, + AppID: app.ID, + AppSecretID: uuid.NullUUID{UUID: dbSecret.ID, Valid: true}, APIKeyID: newKey.ID, UserID: dbCode.UserID, Audience: dbCode.ResourceUri, @@ -468,6 +469,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut ExpiresAt: refreshExpiresAt, HashPrefix: []byte(refreshToken.Prefix), RefreshHash: refreshToken.Hashed, + AppID: app.ID, AppSecretID: dbToken.AppSecretID, APIKeyID: newKey.ID, UserID: dbToken.UserID, From 83d7734ebc4a4ae3dace7c9ba0e5263206692fcc Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 4 Aug 2026 13:16:02 -0700 Subject: [PATCH 2/3] fix(coderd/oauth2provider): bind minted tokens to the authenticated client extractOAuth2ProviderAppBase resolves the app purely from the request's client_id (URL param, query, form, or Basic auth username) with no secret verification at that stage. authorizationCodeGrant and refreshTokenGrant both wrote the new app_id column from that unauthenticated value without checking it against the credential actually being redeemed, so a request presenting a valid secret/code/ refresh token for one app, but a different app's client_id, would mint or refresh a token attributed to the wrong app. Since revoke.go now checks app_id directly (rather than joining through app_secret_id, which is null for public clients), a stolen refresh token could be refreshed under an attacker-chosen client_id, re-parenting the token's app_id so the app that actually issued it could no longer revoke it through RFC 7009 or the per-app token list. Add the missing checks: authorizationCodeGrant now rejects when dbSecret.AppID or dbCode.AppID doesn't match the request's client_id, and refreshTokenGrant rejects when dbToken.AppID doesn't match. Each reuses the grant's existing error for that credential (errBadSecret, errBadCode, errBadToken) rather than a distinguishable "wrong app" error, and the inserted app_id is now sourced from the validated credential (dbCode.AppID, dbToken.AppID) instead of the request. Add regression coverage: TestOAuth2ProviderTokenExchange gains a case for a secret belonging to a different app than client_id, a new TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp isolates the code-ownership check (it can only be reached when the two apps involved share an identical callback URL, since a request-level redirect_uri check would otherwise mask it behind an unrelated mismatch error), TestOAuth2ProviderTokenRefresh gains a cross-app refresh case, and a new TestOAuth2ProviderRevokeCrossApp covers both the access-token and refresh-token revocation branches revoking under a different app than the one that issued the token. Fixes CRF-4, CRF-5, and CRF-6 from https://github.com/coder/coder/pull/27712#pullrequestreview-4824555923 Co-Authored-By: Claude Sonnet 5 --- coderd/oauth2_test.go | 198 +++++++++++++++++++++++++++++++- coderd/oauth2provider/tokens.go | 29 ++++- 2 files changed, 220 insertions(+), 7 deletions(-) diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 448e737e819..3a8d5917fda 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -446,6 +446,16 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) { return err }, }, + { + // secret belongs to apps.Default (see the shared "secret" above), + // but this app's client_id is apps.NoPort. The token endpoint + // must reject a secret that belongs to a different app than the + // one identified by client_id, rather than trusting client_id + // alone to attribute the resulting token. + name: "SecretBelongsToDifferentApp", + app: apps.NoPort, + tokenError: "The client credentials are invalid", + }, { name: "OK", app: apps.Default, @@ -531,6 +541,76 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) { } } +// TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp covers +// authorizationCodeGrant's code-ownership check in isolation. The token +// endpoint validates redirect_uri against the app resolved from client_id +// (before the grant runs at all) and separately against the redirect_uri +// recorded on the code itself (inside the grant). Both must pass for the +// request to reach the code-ownership check, which only happens when the +// app identified by client_id and the app that originally issued the code +// happen to share the exact same callback URL, which is plausible for +// native clients that commonly register a conventional localhost +// redirect. Two apps with distinct callbacks (as in the table above) can +// never reach this check via a redirect_uri mismatch; this test +// constructs the one scenario that does. +func TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp(t *testing.T) { + t.Parallel() + + ownerClient := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, ownerClient) + ctx := testutil.Context(t, testutil.WaitLong) + + const sharedCallback = "http://localhost1:8080/foo/bar" + createApp := func(name string) (codersdk.OAuth2ProviderApp, codersdk.OAuth2ProviderAppSecretFull) { + //nolint:gocritic // OAauth2 app management requires owner permission. + app, err := ownerClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: fmt.Sprintf("%s-%d", name, time.Now().UnixNano()), + CallbackURL: sharedCallback, + }) + require.NoError(t, err) + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + return app, secret + } + appA, _ := createApp("code-owner") + appB, secretB := createApp("code-thief") + + userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + cfgA := &oauth2.Config{ + ClientID: appA.ID.String(), + Endpoint: oauth2.Endpoint{ + AuthURL: appA.Endpoints.Authorization, + TokenURL: appA.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: sharedCallback, + Scopes: []string{}, + } + code, verifier, err := authorizationFlow(ctx, userClient, cfgA) + require.NoError(t, err) + + // Exchange the code issued for appA, but presenting appB's client_id + // and appB's own valid secret. redirect_uri is identical for both + // apps, so both the request-level check (against the app resolved + // from client_id) and the grant's own check (against the code's + // recorded redirect_uri) pass, isolating the code-ownership check. + cfgB := &oauth2.Config{ + ClientID: appB.ID.String(), + ClientSecret: secretB.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + TokenURL: appB.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: sharedCallback, + Scopes: []string{}, + } + _, err = cfgB.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier)) + require.Error(t, err) + require.ErrorContains(t, err, "The authorization code is invalid or expired") +} + func TestOAuth2ProviderTokenRefresh(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -552,6 +632,12 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { tests := []struct { name string app codersdk.OAuth2ProviderApp + // refreshAsApp, if set, performs the refresh request under this + // app's client_id/endpoints instead of app, while the token itself + // still belongs to app. Used to test that refreshing a token under + // a different app's client_id is rejected outright, rather than + // silently re-parenting the token to the presented client_id. + refreshAsApp *codersdk.OAuth2ProviderApp // If null, assume the token should be valid. defaultToken *string error string @@ -593,6 +679,18 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { expires: time.Now().Add(time.Minute * -1), error: "The refresh token is invalid or expired", }, + { + // The token belongs to apps.Default, but the refresh request + // presents apps.NoPort's client_id. This must be rejected + // outright: silently accepting it (and re-parenting the + // token's app_id to whatever client_id is presented) would let + // a stolen refresh token be laundered to a different app, + // after which the issuing app could no longer revoke it. + name: "WrongApp", + app: apps.Default, + refreshAsApp: &apps.NoPort, + error: "The refresh token is invalid or expired", + }, { name: "OK", app: apps.Default, @@ -644,16 +742,20 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { require.NoError(t, err) require.Equal(t, user.ID, gotUser.ID) + refreshAsApp := test.app + if test.refreshAsApp != nil { + refreshAsApp = *test.refreshAsApp + } cfg := &oauth2.Config{ - ClientID: test.app.ID.String(), + ClientID: refreshAsApp.ID.String(), ClientSecret: secret.ClientSecretFull, Endpoint: oauth2.Endpoint{ - AuthURL: test.app.Endpoints.Authorization, - DeviceAuthURL: test.app.Endpoints.DeviceAuth, - TokenURL: test.app.Endpoints.Token, + AuthURL: refreshAsApp.Endpoints.Authorization, + DeviceAuthURL: refreshAsApp.Endpoints.DeviceAuth, + TokenURL: refreshAsApp.Endpoints.Token, AuthStyle: oauth2.AuthStyleInParams, }, - RedirectURL: test.app.CallbackURL, + RedirectURL: refreshAsApp.CallbackURL, Scopes: []string{}, } @@ -856,6 +958,92 @@ func TestOAuth2ProviderRevoke(t *testing.T) { } } +// TestOAuth2ProviderRevokeCrossApp covers RFC 7009 revocation's ownership +// check, which compares a token's app_id directly rather than joining +// through app_secret_id. That rewrite had zero test coverage on its +// unequal branch: revoking a token while presenting a different app's +// client_id than the one that issued it must be rejected (masked as a +// success per RFC 7009, since revocation must not reveal whether a token +// exists), and must leave the token's session intact. Revoking under the +// correct, issuing app must still work. +func TestOAuth2ProviderRevokeCrossApp(t *testing.T) { + t.Parallel() + + ownerClient := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, ownerClient) + ctx := testutil.Context(t, testutil.WaitLong) + apps := generateApps(ctx, t, ownerClient, "revoke-cross-app") + + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID) + require.NoError(t, err) + + tests := []struct { + name string + // tokenFor extracts the token under test from a successful exchange, + // covering both the refresh-token (revokeRefreshTokenInTx) and + // access-token (revokeAPIKeyInTx) revocation branches. + tokenFor func(*oauth2.Token) string + }{ + { + name: "AccessToken", + tokenFor: func(tok *oauth2.Token) string { return tok.AccessToken }, + }, + { + name: "RefreshToken", + tokenFor: func(tok *oauth2.Token) string { return tok.RefreshToken }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + cfg := &oauth2.Config{ + ClientID: apps.Default.ID.String(), + ClientSecret: secret.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + AuthURL: apps.Default.Endpoints.Authorization, + DeviceAuthURL: apps.Default.Endpoints.DeviceAuth, + TokenURL: apps.Default.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: apps.Default.CallbackURL, + Scopes: []string{}, + } + + code, verifier, err := authorizationFlow(ctx, userClient, cfg) + require.NoError(t, err) + token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier)) + require.NoError(t, err) + + sessionWorks := func() bool { + checkClient := codersdk.New(userClient.URL) + checkClient.SetSessionToken(token.AccessToken) + _, err := checkClient.User(ctx, codersdk.Me) + return err == nil + } + require.True(t, sessionWorks(), "session should be valid before any revoke attempt") + + tokenUnderTest := test.tokenFor(token) + + // RFC 7009: revoking under a different app than the one that + // issued the token must not reveal whether it exists (no + // error), and must not actually end the session. + err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, tokenUnderTest) + require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009") + require.True(t, sessionWorks(), "cross-app revoke must not actually end the session") + + // Revoking under the correct, issuing app must actually work. + err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, tokenUnderTest) + require.NoError(t, err) + require.False(t, sessionWorks(), "same-app revoke must end the session") + }) + } +} + type provisionedApps struct { Default codersdk.OAuth2ProviderApp NoPort codersdk.OAuth2ProviderApp diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 0d00d05ec21..3761d1010ca 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -231,6 +231,15 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database return codersdk.OAuth2TokenResponse{}, errBadSecret } + // The secret must belong to the app identified by the request's + // client_id, which is otherwise unauthenticated at this point (it is + // parsed straight from the request with no verification). Without this + // check, a valid secret for one app could mint a token attributed to a + // different app. + if dbSecret.AppID != app.ID { + return codersdk.OAuth2TokenResponse{}, errBadSecret + } + // Validate the authorization code. code, err := ParseFormattedSecret(req.Code) if err != nil { @@ -249,6 +258,12 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database return codersdk.OAuth2TokenResponse{}, errBadCode } + // The code must belong to the app identified by the request's + // client_id, for the same reason as the secret check above. + if dbCode.AppID != app.ID { + return codersdk.OAuth2TokenResponse{}, errBadCode + } + // Ensure the code has not expired. if dbCode.ExpiresAt.Before(dbtime.Now()) { return codersdk.OAuth2TokenResponse{}, errBadCode @@ -355,7 +370,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database ExpiresAt: refreshExpiresAt, HashPrefix: []byte(refreshToken.Prefix), RefreshHash: refreshToken.Hashed, - AppID: app.ID, + AppID: dbCode.AppID, AppSecretID: uuid.NullUUID{UUID: dbSecret.ID, Valid: true}, APIKeyID: newKey.ID, UserID: dbCode.UserID, @@ -398,6 +413,16 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut return codersdk.OAuth2TokenResponse{}, errBadToken } + // The token must belong to the app identified by the request's + // client_id, which is otherwise unauthenticated at this point (it is + // parsed straight from the request with no verification). Without this + // check, a stolen refresh token could be refreshed under a different + // app's client_id, re-parenting the token's app_id and breaking the + // issuing app's ability to revoke it. + if dbToken.AppID != app.ID { + return codersdk.OAuth2TokenResponse{}, errBadToken + } + // Ensure the token has not expired. if dbToken.ExpiresAt.Before(dbtime.Now()) { return codersdk.OAuth2TokenResponse{}, errBadToken @@ -469,7 +494,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut ExpiresAt: refreshExpiresAt, HashPrefix: []byte(refreshToken.Prefix), RefreshHash: refreshToken.Hashed, - AppID: app.ID, + AppID: dbToken.AppID, AppSecretID: dbToken.AppSecretID, APIKeyID: newKey.ID, UserID: dbToken.UserID, From dfb3c3c5da9edfd9a6b7f2b2ae865f9250af035a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 4 Aug 2026 16:53:46 -0700 Subject: [PATCH 3/3] fix(coderd/database/dbgen): allow seeding tokens with a NULL app_secret_id Both `takeFirst` defaults on `OAuth2ProviderAppToken` are unreachable or broken. `app_secret_id` has an FK to `oauth2_provider_app_secrets(id)`, so defaulting it to a random UUID was always a constraint violation, and this branch's new FK on `app_id` does the same to that default. They survive only because every caller overrides them. Making `app_secret_id` nullable also made NULL a legal value that `takeFirst` cannot express, since NULL is its "unset" sentinel: a caller passing `uuid.NullUUID{}` to seed a public client's secretless token silently gets a random secret ID instead. Pass `app_secret_id` through verbatim, and require `app_id` with an assertion naming the helper to build the parent, matching `dbgen.GroupMember`. All existing callers already set both fields. Co-Authored-By: Claude Opus 5 (1M context) --- coderd/database/dbgen/dbgen.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 55098441d68..577705ec6b5 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1789,14 +1789,18 @@ func OAuth2ProviderAppCode(t testing.TB, db database.Store, seed database.OAuth2 } func OAuth2ProviderAppToken(t testing.TB, db database.Store, seed database.OAuth2ProviderAppToken) database.OAuth2ProviderAppToken { + require.NotEqual(t, uuid.Nil, seed.AppID, "An app id is required to use 'dbgen.OAuth2ProviderAppToken', use 'dbgen.OAuth2ProviderApp'.") token, err := db.InsertOAuth2ProviderAppToken(genCtx, database.InsertOAuth2ProviderAppTokenParams{ ID: takeFirst(seed.ID, uuid.New()), CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), ExpiresAt: takeFirst(seed.CreatedAt, dbtime.Now()), HashPrefix: takeFirstSlice(seed.HashPrefix, []byte("prefix")), RefreshHash: takeFirstSlice(seed.RefreshHash, []byte("hashed-secret")), - AppID: takeFirst(seed.AppID, uuid.New()), - AppSecretID: takeFirst(seed.AppSecretID, uuid.NullUUID{UUID: uuid.New(), Valid: true}), + AppID: seed.AppID, + // Public (secretless) clients reference no secret, so a zero-value + // NullUUID is passed through as NULL rather than defaulted. takeFirst + // cannot express that, since NULL is its "unset" sentinel. + AppSecretID: seed.AppSecretID, APIKeyID: takeFirst(seed.APIKeyID, uuid.New().String()), UserID: takeFirst(seed.UserID, uuid.New()), Audience: seed.Audience,