-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(coderd): support public OAuth2 client tokens at the schema layer #27712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0853bb0
83d7734
a564763
dfb3c3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
BobbyHo marked this conversation as resolved.
|
||
|
|
||
| -- 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; | ||
|
BobbyHo marked this conversation as resolved.
|
||
|
|
||
| -- 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.'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', '[email protected]', ''::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") | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.