diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6aeccb925c3..50d57a6c5e0 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2136,6 +2136,10 @@ func (q *querier) DeleteAPIKeyByID(ctx context.Context, id string) error { return deleteQ(q.log, q.auth, q.db.GetAPIKeyByID, q.db.DeleteAPIKeyByID)(ctx, id) } +func (q *querier) DeleteAPIKeyByIDReturningRow(ctx context.Context, id string) (database.APIKey, error) { + return fetchAndQuery(q.log, q.auth, policy.ActionDelete, q.db.GetAPIKeyByID, q.db.DeleteAPIKeyByIDReturningRow)(ctx, id) +} + func (q *querier) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { // TODO: This is not 100% correct because it omits apikey IDs. err := q.authorizeContext(ctx, policy.ActionDelete, diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 2d6e3df865c..3d96a991524 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -84,6 +84,40 @@ func TestPing(t *testing.T) { require.NoError(t, err, "must not error") } +// TestSingleUseDeleteNotFound pins that a fetch-then-query wrapper whose +// fetch finds nothing returns an error that still matches sql.ErrNoRows, and +// never reaches the query. The OAuth2 grants rely on both to answer +// invalid_grant when a single-use delete finds its row already gone. +func TestSingleUseDeleteNotFound(t *testing.T) { + t.Parallel() + + ctx := dbauthz.As(context.Background(), coderdtest.RandomRBACSubject()) + newQuerier := func(t *testing.T) (*dbmock.MockStore, database.Store) { + db := dbmock.NewMockStore(gomock.NewController(t)) + db.EXPECT().Wrappers().Return([]string{}).AnyTimes() + return db, dbauthz.New(db, &coderdtest.RecordingAuthorizer{}, slog.Make(), coderdtest.AccessControlStorePointer()) + } + + t.Run("DeleteAPIKeyByIDReturningRow", func(t *testing.T) { + t.Parallel() + db, q := newQuerier(t) + db.EXPECT().GetAPIKeyByID(gomock.Any(), "gone").Return(database.APIKey{}, sql.ErrNoRows) + + _, err := q.DeleteAPIKeyByIDReturningRow(ctx, "gone") + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("DeleteOAuth2ProviderAppCodeByID", func(t *testing.T) { + t.Parallel() + db, q := newQuerier(t) + id := uuid.New() + db.EXPECT().GetOAuth2ProviderAppCodeByID(gomock.Any(), id).Return(database.OAuth2ProviderAppCode{}, sql.ErrNoRows) + + _, err := q.DeleteOAuth2ProviderAppCodeByID(ctx, id) + require.ErrorIs(t, err, sql.ErrNoRows) + }) +} + // TestInTX is not perfect, just checks that it properly checks auth. func TestInTX(t *testing.T) { t.Parallel() @@ -459,6 +493,12 @@ func (s *MethodTestSuite) TestAPIKey() { dbm.EXPECT().DeleteAPIKeyByID(gomock.Any(), key.ID).Return(nil).AnyTimes() check.Args(key.ID).Asserts(key, policy.ActionDelete).Returns() })) + s.Run("DeleteAPIKeyByIDReturningRow", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.APIKey{}) + dbm.EXPECT().GetAPIKeyByID(gomock.Any(), key.ID).Return(key, nil).AnyTimes() + dbm.EXPECT().DeleteAPIKeyByIDReturningRow(gomock.Any(), key.ID).Return(key, nil).AnyTimes() + check.Args(key.ID).Asserts(key, policy.ActionDelete).Returns(key) + })) s.Run("DeleteExpiredAPIKeys", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { args := database.DeleteExpiredAPIKeysParams{ Before: time.Date(2025, 11, 21, 0, 0, 0, 0, time.UTC), diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 57b81e265d7..c0427841c2b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -449,6 +449,14 @@ func (m queryMetricsStore) DeleteAPIKeyByID(ctx context.Context, id string) erro return r0 } +func (m queryMetricsStore) DeleteAPIKeyByIDReturningRow(ctx context.Context, id string) (database.APIKey, error) { + start := time.Now() + r0, r1 := m.s.DeleteAPIKeyByIDReturningRow(ctx, id) + m.queryLatencies.WithLabelValues("DeleteAPIKeyByIDReturningRow").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteAPIKeyByIDReturningRow").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { start := time.Now() r0 := m.s.DeleteAPIKeysByUserID(ctx, userID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 8fa57a596c2..71140d84816 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -719,6 +719,21 @@ func (mr *MockStoreMockRecorder) DeleteAPIKeyByID(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAPIKeyByID", reflect.TypeOf((*MockStore)(nil).DeleteAPIKeyByID), ctx, id) } +// DeleteAPIKeyByIDReturningRow mocks base method. +func (m *MockStore) DeleteAPIKeyByIDReturningRow(ctx context.Context, id string) (database.APIKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAPIKeyByIDReturningRow", ctx, id) + ret0, _ := ret[0].(database.APIKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteAPIKeyByIDReturningRow indicates an expected call of DeleteAPIKeyByIDReturningRow. +func (mr *MockStoreMockRecorder) DeleteAPIKeyByIDReturningRow(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAPIKeyByIDReturningRow", reflect.TypeOf((*MockStore)(nil).DeleteAPIKeyByIDReturningRow), ctx, id) +} + // DeleteAPIKeysByUserID mocks base method. func (m *MockStore) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b7c063b98a0..61c979c0132 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -121,6 +121,14 @@ type sqlcQuerier interface { DeleteAIProviderByID(ctx context.Context, id uuid.UUID) error DeleteAIProviderKey(ctx context.Context, id uuid.UUID) error DeleteAPIKeyByID(ctx context.Context, id string) error + // Returns sql.ErrNoRows when the delete removed nothing, so a caller can make + // this the arbiter of single use. A prior read cannot arbitrate: its result is + // stale the moment it returns. + // + // Concurrent deletes are arbitrated at READ COMMITTED, the default isolation + // level: the second transaction waits for the first, then removes nothing. + // SERIALIZABLE would abort and retry it instead. + DeleteAPIKeyByIDReturningRow(ctx context.Context, id string) (APIKey, error) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error // Deletes all heartbeat rows for the chat. Used during ownership // transitions that abandon a lease. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index babef6c49ec..fed315df803 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -19678,6 +19678,22 @@ func TestSingleUseDelete(t *testing.T) { _, err = db.DeleteOAuth2ProviderAppCodeByID(ctx, code.ID) require.ErrorIs(t, err, sql.ErrNoRows) }) + + t.Run("APIKey", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + key, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) + + deleted, err := db.DeleteAPIKeyByIDReturningRow(ctx, key.ID) + require.NoError(t, err) + require.Equal(t, key, deleted) + + _, err = db.DeleteAPIKeyByIDReturningRow(ctx, key.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + }) } func TestGetUnpricedAIModelsSince(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 07ef2ceebde..58b0366cece 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3818,6 +3818,42 @@ func (q *sqlQuerier) DeleteAPIKeyByID(ctx context.Context, id string) error { return err } +const deleteAPIKeyByIDReturningRow = `-- name: DeleteAPIKeyByIDReturningRow :one +DELETE FROM + api_keys +WHERE + id = $1 +RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list +` + +// Returns sql.ErrNoRows when the delete removed nothing, so a caller can make +// this the arbiter of single use. A prior read cannot arbitrate: its result is +// stale the moment it returns. +// +// Concurrent deletes are arbitrated at READ COMMITTED, the default isolation +// level: the second transaction waits for the first, then removes nothing. +// SERIALIZABLE would abort and retry it instead. +func (q *sqlQuerier) DeleteAPIKeyByIDReturningRow(ctx context.Context, id string) (APIKey, error) { + row := q.db.QueryRowContext(ctx, deleteAPIKeyByIDReturningRow, id) + var i APIKey + err := row.Scan( + &i.ID, + &i.HashedSecret, + &i.UserID, + &i.LastUsed, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.LoginType, + &i.LifetimeSeconds, + &i.IPAddress, + &i.TokenName, + &i.Scopes, + &i.AllowList, + ) + return i, err +} + const deleteAPIKeysByUserID = `-- name: DeleteAPIKeysByUserID :exec DELETE FROM api_keys diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 90e7610cf06..feb2b320041 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -92,6 +92,20 @@ DELETE FROM WHERE id = $1; +-- name: DeleteAPIKeyByIDReturningRow :one +-- Returns sql.ErrNoRows when the delete removed nothing, so a caller can make +-- this the arbiter of single use. A prior read cannot arbitrate: its result is +-- stale the moment it returns. +-- +-- Concurrent deletes are arbitrated at READ COMMITTED, the default isolation +-- level: the second transaction waits for the first, then removes nothing. +-- SERIALIZABLE would abort and retry it instead. +DELETE FROM + api_keys +WHERE + id = $1 +RETURNING *; + -- name: DeleteApplicationConnectAPIKeysByUserID :exec DELETE FROM api_keys diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 12e5f12a053..461dc7c7825 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -138,7 +138,9 @@ func narrowAccessScope(ctx context.Context, logger slog.Logger, phase string, ap func scopeStringToAPIKeyScopes(scope string) (database.APIKeyScopes, error) { names := strings.Fields(scope) if len(names) == 0 { - return nil, xerrors.Errorf("'%s': %w", scope, errUnmintableScope) + // Fixed message rather than an echo: CHECK (scope <> '') admits a + // whitespace-only value, which names nothing worth reporting back. + return nil, xerrors.Errorf("the grant names no scope: %w", errUnmintableScope) } scopes := make(database.APIKeyScopes, 0, len(names)) @@ -411,6 +413,14 @@ func revokeOAuth2CodeOnPKCEFailure(ctx context.Context, db database.Store, codeI } } +// singleUseTxOptions names the isolation level the single-use deletes need. +// At READ COMMITTED a second delete waits for the first to commit and then +// removes nothing; higher levels raise a serialization error instead. +// Built per call because InTx writes to the options it receives. +func singleUseTxOptions() *database.TxOptions { + return &database.TxOptions{Isolation: sql.LevelReadCommitted} +} + func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog.Logger, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) { // A public client has no secret to validate, and its token references // none. PKCE and the dbCode.AppID check are what bind the exchange to the @@ -585,6 +595,8 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog. // token, so a later failure leaves the code redeemable. _, err := tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID) if errors.Is(err, sql.ErrNoRows) { + logger.Warn(ctx, "oauth2 code redemption refused: code already used", + slog.F("app_id", app.ID), slog.F("code_id", dbCode.ID)) return errBadCode } if err != nil { @@ -625,7 +637,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog. return xerrors.Errorf("insert oauth2 refresh token: %w", err) } return nil - }, nil) + }, singleUseTxOptions()) if err != nil { return codersdk.OAuth2TokenResponse{}, err } @@ -687,15 +699,10 @@ func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logge return codersdk.OAuth2TokenResponse{}, err } - // Grab the user roles so we can perform the refresh as the user. - //nolint:gocritic // OAuth2 system context, need to read the previous API key - prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID) - if err != nil { - return codersdk.OAuth2TokenResponse{}, err - } - + // The token row carries the user id, so the previous key is not read + // before the delete below decides which of two refreshes proceeds. // ScopeAll for the same reason as in authorizationCodeGrant. - actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, rbac.ScopeAll) + actor, _, err := httpmw.UserRBACSubject(ctx, db, dbToken.UserID, rbac.ScopeAll) if err != nil { return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err) } @@ -712,9 +719,9 @@ func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logge } // Generate the new API key. - tokenName := fmt.Sprintf("%s_%s_oauth_session_token", prevKey.UserID, app.ID) + tokenName := fmt.Sprintf("%s_%s_oauth_session_token", dbToken.UserID, app.ID) key, sessionToken, err := apikey.Generate(apikey.CreateParams{ - UserID: prevKey.UserID, + UserID: dbToken.UserID, LoginType: database.LoginTypeOAuth2ProviderApp, DefaultLifetime: lifetimes.DefaultDuration.Value(), Scopes: scopes, @@ -735,7 +742,18 @@ func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logge err = db.InTx(func(tx database.Store) error { ctx := dbauthz.As(ctx, actor) - err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token. + // RFC 6749 ยง10.4: the presented refresh token is invalidated so that a + // second use of it can be detected. Only one of two concurrent + // refreshes can delete this row; the other waits for this transaction + // to commit, finds nothing, and is refused. A failure below rolls the + // delete back, so the old key stays usable. + _, err := tx.DeleteAPIKeyByIDReturningRow(ctx, dbToken.APIKeyID) // This cascades to the token. + if errors.Is(err, sql.ErrNoRows) { + // The one place a second use of a refresh token is visible. + logger.Warn(ctx, "oauth2 refresh refused: refresh token already used", + slog.F("app_id", app.ID), slog.F("api_key_id", dbToken.APIKeyID)) + return errBadToken + } if err != nil { return xerrors.Errorf("delete oauth2 app token: %w", err) } @@ -766,7 +784,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logge return xerrors.Errorf("insert oauth2 refresh token: %w", err) } return nil - }, nil) + }, singleUseTxOptions()) if err != nil { return codersdk.OAuth2TokenResponse{}, err } diff --git a/coderd/oauth2provider/tokens_internal_test.go b/coderd/oauth2provider/tokens_internal_test.go index 19c7edd9c20..3c787240c62 100644 --- a/coderd/oauth2provider/tokens_internal_test.go +++ b/coderd/oauth2provider/tokens_internal_test.go @@ -80,12 +80,19 @@ func TestScopeStringToAPIKeyScopes(t *testing.T) { // Unreachable through the NOT NULL column, but pinned: apikey.Generate reads // an empty list as unrestricted, so anything but an error widens the grant. + // CHECK (scope <> '') admits every value here but the first. t.Run("EmptyRejected", func(t *testing.T) { t.Parallel() - for _, scope := range []string{"", " "} { + var first string + for _, scope := range []string{"", " ", "\t", "\n", " \t\r\n "} { _, err := scopeStringToAPIKeyScopes(scope) require.ErrorIs(t, err, errUnmintableScope, "scope %q", scope) + if first == "" { + first = err.Error() + } + assert.Equal(t, first, err.Error(), + "a scope naming nothing has nothing to echo, so the message cannot vary with it") } }) } diff --git a/coderd/oauth2provider/tokens_test.go b/coderd/oauth2provider/tokens_test.go index 285cd84ba84..a26e34e41c1 100644 --- a/coderd/oauth2provider/tokens_test.go +++ b/coderd/oauth2provider/tokens_test.go @@ -355,6 +355,24 @@ func TestOAuth2TokenExchangeScope(t *testing.T) { "an operator cannot act on this without knowing which stored name is the problem") }) + // The same stale row reached through a refresh rather than a code. A grant + // outlives the code that issued it, so this is the likelier way a name + // removed from the enum surfaces. + t.Run("StoredScopeOutsideEnumRejectedOnRefresh", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true}) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, scopeOutOfCatalog) + + status, body := postTokenRequest(ctx, t, client, refreshForm(app, refreshToken)) + + description := requireTokenGrantError(t, status, body) + require.Contains(t, description, oauth2provider.ReasonUnmintableScope) + require.Contains(t, description, scopeOutOfCatalog, + "an operator cannot act on this without knowing which stored name is the problem") + }) + t.Run("AllowlistNarrowedAfterAuthorizationRejected", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -477,52 +495,88 @@ func TestOAuth2TokenExchangeSingleUse(t *testing.T) { t.Parallel() db, pubsub := dbtestutil.NewDB(t) - var reads sync.WaitGroup - reads.Add(2) + reads := newBarrier() + client := coderdtest.New(t, &coderdtest.Options{ + Database: barrierStore{Store: db, codeReads: reads}, + Pubsub: pubsub, + }) + coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{}) + code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "") + + accepted := requireExactlyOneAccepted(ctx, t, client, tokenExchangeForm(app, code, verifier)) + require.Equal(t, racers, reads.arrivals(), "both requests must read the code before either deletes it") + requireTokenAuthenticates(ctx, t, client, accepted.AccessToken) +} + +func TestOAuth2RefreshSingleUse(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + reads := newBarrier() client := coderdtest.New(t, &coderdtest.Options{ - Database: barrierStore{Store: db, reads: &reads}, + Database: barrierStore{Store: db, tokenReads: reads}, Pubsub: pubsub, }) coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) + // Unnarrowed, so the accepted token can be checked against /users/me. app := seedAppWithSecret(t, db, sql.NullString{}) code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "") - form := tokenExchangeForm(app, code, verifier) + token := exchangeCode(ctx, t, client, app, code, verifier) + + accepted := requireExactlyOneAccepted(ctx, t, client, refreshForm(app, token.RefreshToken)) + require.Equal(t, racers, reads.arrivals(), "both requests must read the token before either deletes it") + requireTokenAuthenticates(ctx, t, client, accepted.AccessToken) + requireRefreshTokenSpent(ctx, t, db, token.RefreshToken) +} + +// requireExactlyOneAccepted sends the same token request twice at once and +// returns the accepted response. The other request must be refused with +// invalid_grant. +func requireExactlyOneAccepted(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values) codersdk.OAuth2TokenResponse { + t.Helper() - type exchange struct { + type attempt struct { status int body string err error } - redeem := func() exchange { + var start sync.WaitGroup + start.Add(racers) + send := func() attempt { + start.Done() + start.Wait() status, body, err := tryTokenRequest(ctx, t, client, form) - return exchange{status: status, body: body, err: err} + return attempt{status: status, body: body, err: err} } - other := make(chan exchange, 1) - go func() { other <- redeem() }() - results := []exchange{redeem(), <-other} + other := make(chan attempt, 1) + go func() { other <- send() }() + results := []attempt{send(), <-other} - var winner codersdk.OAuth2TokenResponse - var minted, rejected int + var accepted codersdk.OAuth2TokenResponse + var ok, refused int for _, result := range results { require.NoError(t, result.err) switch result.status { case http.StatusOK: - winner = requireTokenResponse(t, result.status, result.body) - minted++ + accepted = requireTokenResponse(t, result.status, result.body) + ok++ case http.StatusBadRequest: require.Contains(t, result.body, string(codersdk.OAuth2ErrorCodeInvalidGrant), result.body) - rejected++ + refused++ default: t.Fatalf("unexpected status %d: %s", result.status, result.body) } } - require.Equal(t, 1, minted, "a code may mint at most one token") - require.Equal(t, 1, rejected) - requireTokenAuthenticates(ctx, t, client, winner.AccessToken) + require.Equal(t, 1, ok, "exactly one of two concurrent requests must be accepted") + require.Equal(t, 1, refused, "the other request must be refused with invalid_grant") + return accepted } // The ordinary replay: a client retries a redemption whose answer it never saw. @@ -549,28 +603,166 @@ func TestOAuth2TokenExchangeReplay(t *testing.T) { requireTokenAuthenticates(ctx, t, client, token.AccessToken) } -// barrierStore holds each redemption at its code read until every redemption -// has read, so both reach the delete with the same stale view. Starting the -// requests together is not enough on its own: nothing stops one handler from -// committing before the other reads, and the read then refuses the second -// before the delete ever arbitrates. +// A revoked token must refresh as invalid_grant rather than as a server fault. +// Deleting either row cascades the token row away, so the prefix lookup is +// what refuses these. They are pinned anyway: the property a client depends on +// is the response, not which statement notices, and the cascades that produce +// it are schema the refresh does not control. +func TestOAuth2RefreshRevokedToken(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }) + owner := coderdtest.CreateFirstUser(t, client) + + t.Run("KeyDeleted", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true}) + code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "workspace:ssh") + token := exchangeCode(ctx, t, client, app, code, verifier) + + keyID := tokenRow(ctx, t, db, token.RefreshToken).APIKeyID + require.NoError(t, client.DeleteAPIKey(ctx, owner.UserID.String(), keyID)) + + status, body := postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken)) + requireTokenGrantError(t, status, body) + }) + + t.Run("AppSecretDeleted", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true}) + code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "workspace:ssh") + token := exchangeCode(ctx, t, client, app, code, verifier) + + require.NoError(t, client.DeleteOAuth2ProviderAppSecret(ctx, app.ID, app.SecretID)) + + status, body := postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken)) + requireTokenGrantError(t, status, body) + }) + + // The third revocation path FR12 names. It never reaches the grant: the + // client_id no longer resolves, so authentication refuses first. + t.Run("AppDeleted", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true}) + code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "workspace:ssh") + token := exchangeCode(ctx, t, client, app, code, verifier) + + require.NoError(t, client.DeleteOAuth2ProviderApp(ctx, app.ID)) + + status, body := postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken)) + require.Equal(t, http.StatusUnauthorized, status, body) + require.Contains(t, body, string(codersdk.OAuth2ErrorCodeInvalidClient), body) + }) +} + +// A token row whose api_key_id names no key. The FK cascade makes that +// unreachable through any API, so the constraints come off to seed it, and +// this test takes a database of its own because disabling them applies to +// every table in it. The refresh reads nothing from api_keys before the +// returning-row delete, so the missing key surfaces there as invalid_grant; +// a read of the key ahead of the delete answered HTTP 500 here. +func TestOAuth2RefreshKeyMissing(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }) + owner := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true}) + refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh") + + dbtestutil.DisableForeignKeysAndTriggers(t, db) + require.NoError(t, db.DeleteAPIKeyByID(dbauthz.AsSystemRestricted(ctx), + tokenRow(ctx, t, db, refreshToken).APIKeyID)) + + status, body := postTokenRequest(ctx, t, client, refreshForm(app, refreshToken)) + requireTokenGrantError(t, status, body) +} + +// racers is how many requests the single-use tests send at once. +const racers = 2 + +// barrierStore holds each request at a chosen read until all racers have +// read, so both reach the delete with the same stale view. Starting the +// requests together is not enough on its own: one handler could commit before +// the other reads, and the read would then refuse the second request before +// the delete is ever contested. // // InTx hands its closure a fresh Store, so this intercepts only the read that -// precedes the transaction, which is the one that fixes the interleaving. +// precedes the transaction. type barrierStore struct { database.Store - reads *sync.WaitGroup + codeReads *barrier + tokenReads *barrier } // GetOAuth2ProviderAppCodeByPrefix has one production caller, the code read in // authorizationCodeGrant, so every arrival here is a redemption. func (s barrierStore) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, prefix []byte) (database.OAuth2ProviderAppCode, error) { code, err := s.Store.GetOAuth2ProviderAppCodeByPrefix(ctx, prefix) - s.reads.Done() - s.reads.Wait() + s.codeReads.wait(ctx) return code, err } +func (s barrierStore) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, prefix []byte) (database.OAuth2ProviderAppToken, error) { + token, err := s.Store.GetOAuth2ProviderAppTokenByPrefix(ctx, prefix) + s.tokenReads.wait(ctx) + return token, err +} + +// barrier releases every waiter once racers of them have arrived. A waiter +// also gives up when its context ends, so a missing arrival fails the test +// through the request's own result instead of hanging the package. +type barrier struct { + mu sync.Mutex + arrived int + released chan struct{} +} + +func newBarrier() *barrier { + return &barrier{released: make(chan struct{})} +} + +func (b *barrier) wait(ctx context.Context) { + if b == nil { + return + } + b.mu.Lock() + b.arrived++ + if b.arrived == racers { + close(b.released) + } + b.mu.Unlock() + + select { + case <-b.released: + case <-ctx.Done(): + } +} + +// arrivals is how many requests reached the barrier. Tests assert it equals +// racers, which catches both a request that never got there and an extra +// caller of the intercepted read. +func (b *barrier) arrivals() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.arrived +} + // requireTokenAuthenticates asserts the accepted redemption's own credential // still works. Callers grant coder:all so the probed endpoint is in scope. // @@ -863,6 +1055,17 @@ func tokenRow(ctx context.Context, t *testing.T, db database.Store, refreshToken return dbToken } +// requireRefreshTokenSpent asserts the presented refresh token's row is gone. +func requireRefreshTokenSpent(ctx context.Context, t *testing.T, db database.Store, refreshToken string) { + t.Helper() + + parsed, err := oauth2provider.ParseFormattedSecret(refreshToken) + require.NoError(t, err) + + _, err = db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(parsed.Prefix)) + require.ErrorIs(t, err, sql.ErrNoRows, "a refresh token must not survive its own refresh") +} + func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refreshToken string) database.APIKeyScopes { t.Helper()