mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): support public OAuth2 client tokens at the schema layer (#27712)
Layer 1 of a multi-PR split of #27195 (public/secretless PKCE-only OAuth2 clients), broken up for easier review: **database schema (this PR)** → oauth2provider handler logic → API/e2e integration tests. ## Goal Coder's OAuth2 provider only works correctly for confidential clients today. Public clients — native apps that can't safely hold a shared secret, such as the CLI's browser-based login flow, IDE plugins (VS Code, JetBrains), desktop apps, and MCP clients — cannot complete a real OAuth2 flow against Coder, even though OAuth 2.1 §2.1 explicitly defines this client type and RFC 8252 §8.5 requires PKCE alone to be sufficient authentication for it. Every MCP client, CLI login flow, and IDE plugin is a public client by construction, and none of them can complete a secretless flow against Coder today: dynamic registration always classifies a client as confidential regardless of what it asks for, the token endpoint unconditionally requires a `client_secret`, and discovery metadata never advertises `"none"` as a supported auth method. Full write-up: [ENG-3029](https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client) ### Overall design (end state across the full PR stack) `[PR2]` marks handler-layer changes landing in the next PR in this stack. The green box is what this PR implements. ```mermaid sequenceDiagram autonumber participant C as Public Client (CLI/MCP/IDE plugin) participant S as coderd (chi router) participant H as oauth2provider handlers participant DB as PostgreSQL Note over C,S: Discovery C->>S: GET /.well-known/oauth-authorization-server S->>H: GetAuthorizationServerMetadata() Note over H: [PR2] add "none" to<br/>the returned auth methods list H-->>C: [PR2] 200 { token_endpoint_auth_methods_supported:<br/>[..., "none"] } Note over C,S: Dynamic Client Registration C->>S: POST /oauth2/register<br/>{redirect_uris, token_endpoint_auth_method: "none"} S->>H: CreateDynamicClientRegistration() Note over H: [PR2] client type now reads<br/>the request -> "public" Note over H: [PR2] skip secret generation<br/>for public clients H->>DB: [PR2] INSERT app row<br/>(client_type = 'public') DB-->>H: app row Note over H: [PR2] skip secret insert entirely H-->>C: [PR2] 201 { client_id }<br/>(no client_secret field) Note over C,S: Authorization Code + PKCE flow C->>S: GET /oauth2/authorize?client_id=...&code_challenge=... C->>S: POST /oauth2/tokens (grant_type=authorization_code)<br/>no client_secret S->>H: extractTokenRequest() Note over H: [PR2] client_secret no longer required<br/>for public clients H->>H: authorizationCodeGrant() Note over H: [PR2] skip secret lookup for public clients Note over H: PKCE verification — already mandatory, unchanged rect rgb(198, 239, 206) Note over H,DB: [THIS PR] oauth2_provider_app_tokens.app_id<br/>column added (NOT NULL, populated at insert<br/>time from app.ID) and app_secret_id loosened<br/>to nullable. Revocation now checks app_id<br/>directly. Confidential-client behavior is<br/>unchanged — no public client can be created yet. H->>DB: [PR2] INSERT refresh token row<br/>(no secret reference, for public clients) end DB-->>H: token row H-->>C: 200 { access_token, refresh_token } ``` ## This PR: database schema A public client has no `client_secret`, so it has nothing to put in `oauth2_provider_app_tokens.app_secret_id`, which was `NOT NULL`. This PR makes that column nullable and instead attributes a token to its owning app through a new, always-populated `app_id` column — so ownership checks (e.g. revocation) work identically for public and confidential clients without joining through a secret that may not exist. | Column | Before | After (this PR) | |---|---|---| | `app_secret_id` | `uuid NOT NULL` | **nullable** | | `app_id` | — | **new**: `uuid NOT NULL`, `FOREIGN KEY → oauth2_provider_apps(id) ON DELETE CASCADE`, backfilled for every existing row and populated on every new insert from that point on | This is a single, complete migration — not staged across multiple PRs. An earlier version of this branch deferred `app_secret_id`'s nullability and the insert-time population of `app_id` to a later PR, keeping this PR's diff limited to `coderd/database`. [Automated review](https://github.com/coder/coder/pull/27712#discussion_r3686851911) correctly flagged that as unsafe: the migration would backfill existing rows once, but nothing would populate `app_id` for rows written afterward, so the moment this PR merged, new tokens would start accumulating a permanently `NULL` app_id — and if a release happened to be cut before the follow-up PR landed, that gap could ship to customers and would need a second, later backfill to close. Doing the full migration now avoids that: `app_id` is correct from the first row written, and the promised `NOT NULL` constraint requires no data repair because it's already enforced. Closing that gap requires a few mechanical, non-branching touches outside `coderd/database`: - `revoke.go`'s two ownership checks now compare `dbToken.AppID` directly instead of looking up the app through `app_secret_id` — a genuine simplification (and slightly less code), not a temporary shim. - `tokens.go`'s two `InsertOAuth2ProviderAppToken` call sites supply the new `app_id` column and wrap `app_secret_id` as a `NullUUID`. - `oauth2_test.go`'s one direct-insert test fixture does the same. None of these introduce client-type branching or new capability — every client today is still confidential-only, still always presents a secret, and behavior is unchanged. The full repo builds, vets, and all existing tests pass unmodified in behavior. ## Coming next - **PR2 (handler layer)**: `codersdk`'s `DetermineClientType()` reading the requested `token_endpoint_auth_method`; `registration.go` skipping secret generation for public clients (and wrapping the app+secret insert in a single transaction, fixing a pre-existing orphan-row/visibility-race gap); `tokens.go` making the secret check conditional so PKCE alone authenticates a public client; `metadata.go` advertising `"none"` in discovery. No further migration is needed — the schema this PR ships is already final. - **PR3 (API/e2e layer)**: integration tests through the real HTTP API (`coderd/oauth2_test.go`), the MCP OAuth2 e2e flow (`coderd/mcp/mcp_e2e_test.go`), and the manual test script (`scripts/oauth2/test-mcp-oauth2.sh`). Depends on: #27195 (original combined PR, being superseded by this stack) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
120ec1f318
commit
d814dfad88
@@ -5812,7 +5812,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)),
|
||||
@@ -6020,7 +6021,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)
|
||||
@@ -6035,7 +6037,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,
|
||||
})
|
||||
@@ -6051,7 +6054,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,
|
||||
})
|
||||
@@ -6069,7 +6073,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)),
|
||||
|
||||
@@ -1789,13 +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")),
|
||||
AppSecretID: takeFirst(seed.AppSecretID, uuid.New()),
|
||||
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,
|
||||
|
||||
Generated
+8
-2
@@ -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;
|
||||
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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.';
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Generated
+5
-3
@@ -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 {
|
||||
|
||||
Generated
+6
@@ -166,6 +166,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)
|
||||
@@ -685,6 +688,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)
|
||||
|
||||
Generated
+20
-12
@@ -18777,11 +18777,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
|
||||
`
|
||||
|
||||
@@ -18790,6 +18787,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
|
||||
@@ -18995,7 +18995,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) {
|
||||
@@ -19011,12 +19011,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) {
|
||||
@@ -19032,6 +19033,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hash
|
||||
&i.APIKeyID,
|
||||
&i.Audience,
|
||||
&i.UserID,
|
||||
&i.AppID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -19095,10 +19097,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
|
||||
@@ -19110,6 +19110,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 {
|
||||
@@ -19443,6 +19446,7 @@ INSERT INTO oauth2_provider_app_tokens (
|
||||
expires_at,
|
||||
hash_prefix,
|
||||
refresh_hash,
|
||||
app_id,
|
||||
app_secret_id,
|
||||
api_key_id,
|
||||
user_id,
|
||||
@@ -19456,8 +19460,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 {
|
||||
@@ -19466,7 +19471,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"`
|
||||
@@ -19479,6 +19485,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppToken(ctx context.Context, arg Inser
|
||||
arg.ExpiresAt,
|
||||
arg.HashPrefix,
|
||||
arg.RefreshHash,
|
||||
arg.AppID,
|
||||
arg.AppSecretID,
|
||||
arg.APIKeyID,
|
||||
arg.UserID,
|
||||
@@ -19495,6 +19502,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppToken(ctx context.Context, arg Inser
|
||||
&i.APIKeyID,
|
||||
&i.Audience,
|
||||
&i.UserID,
|
||||
&i.AppID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+195
-6
@@ -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,
|
||||
@@ -630,7 +728,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,
|
||||
})
|
||||
@@ -643,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{},
|
||||
}
|
||||
|
||||
@@ -855,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
|
||||
ExpiresAt: refreshExpiresAt,
|
||||
HashPrefix: []byte(refreshToken.Prefix),
|
||||
RefreshHash: refreshToken.Hashed,
|
||||
AppSecretID: dbSecret.ID,
|
||||
AppID: dbCode.AppID,
|
||||
AppSecretID: uuid.NullUUID{UUID: dbSecret.ID, Valid: true},
|
||||
APIKeyID: newKey.ID,
|
||||
UserID: dbCode.UserID,
|
||||
Audience: dbCode.ResourceUri,
|
||||
@@ -397,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
|
||||
@@ -468,6 +494,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
|
||||
ExpiresAt: refreshExpiresAt,
|
||||
HashPrefix: []byte(refreshToken.Prefix),
|
||||
RefreshHash: refreshToken.Hashed,
|
||||
AppID: dbToken.AppID,
|
||||
AppSecretID: dbToken.AppSecretID,
|
||||
APIKeyID: newKey.ID,
|
||||
UserID: dbToken.UserID,
|
||||
|
||||
Reference in New Issue
Block a user