Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -2115,6 +2115,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,
Expand Down
6 changes: 6 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,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),
Expand Down
8 changes: 8 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19088,6 +19088,22 @@ func TestSingleUseDeleteByIDReturningRow(t *testing.T) {
_, err = db.DeleteOAuth2ProviderAppCodeByIDReturningRow(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 TestGetAIModelPriceByProviderModel(t *testing.T) {
Expand Down
31 changes: 31 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions coderd/database/queries/apikeys.sql
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ DELETE FROM
WHERE
id = $1;

-- name: DeleteAPIKeyByIDReturningRow :one
-- Returns sql.ErrNoRows when the key is already gone, which lets a caller
-- enforce single use by racing this delete instead of reading first.
DELETE FROM
api_keys
WHERE
id = $1
RETURNING *;

-- name: DeleteApplicationConnectAPIKeysByUserID :exec
DELETE FROM
api_keys
Expand Down
7 changes: 6 additions & 1 deletion coderd/oauth2provider/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,12 @@ 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.
// The delete decides the race: only the refresh that removes the key may
// mint a replacement, and the loser sees the token as already spent.
_, err = tx.DeleteAPIKeyByIDReturningRow(ctx, prevKey.ID) // This cascades to the token.
if errors.Is(err, sql.ErrNoRows) {
return errBadToken
}
if err != nil {
return xerrors.Errorf("delete oauth2 app token: %w", err)
}
Expand Down
44 changes: 37 additions & 7 deletions coderd/oauth2provider/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,25 +295,55 @@ func TestOAuth2TokenExchangeSingleUse(t *testing.T) {

app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true})
code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "workspace:ssh")
form := tokenExchangeForm(app, code, verifier)

type exchange struct {
requireExactlyOneMinted(ctx, t, client, tokenExchangeForm(app, code, verifier),
"a code may mint at most one token")
}

// A refresh mints a replacement token and deletes the key the presented one
// hangs off, so the same race as the exchange applies: the deletion has to
// arbitrate, or both requests mint from one refresh token.
func TestOAuth2RefreshSingleUse(t *testing.T) {
t.Parallel()

db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
})
coderdtest.CreateFirstUser(t, client)
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)

requireExactlyOneMinted(ctx, t, client, refreshForm(app, token.RefreshToken),
"a refresh token may mint at most one replacement")
}

// requireExactlyOneMinted posts form twice concurrently and requires one 200
// and one `invalid_grant`.
func requireExactlyOneMinted(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values, msg string) {
t.Helper()

type attempt struct {
status int
body string
}

var barrier sync.WaitGroup
barrier.Add(2)
redeem := func() exchange {
redeem := func() attempt {
barrier.Done()
barrier.Wait()
status, body := postTokenRequest(ctx, t, client, form)
return exchange{status: status, body: body}
return attempt{status: status, body: body}
}

other := make(chan exchange, 1)
other := make(chan attempt, 1)
go func() { other <- redeem() }()
results := []exchange{redeem(), <-other}
results := []attempt{redeem(), <-other}

var minted, rejected int
for _, result := range results {
Expand All @@ -327,7 +357,7 @@ func TestOAuth2TokenExchangeSingleUse(t *testing.T) {
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, minted, msg)
require.Equal(t, 1, rejected)
}

Expand Down
Loading