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 @@ -2375,6 +2375,10 @@ func (q *querier) DeleteOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.U
return q.db.DeleteOAuth2ProviderAppCodeByID(ctx, id)
}

func (q *querier) DeleteOAuth2ProviderAppCodeByIDReturningRow(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppCode, error) {
return fetchAndQuery(q.log, q.auth, policy.ActionDelete, q.db.GetOAuth2ProviderAppCodeByID, q.db.DeleteOAuth2ProviderAppCodeByIDReturningRow)(ctx, id)
}

func (q *querier) DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context, arg database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams) error {
if err := q.authorizeContext(ctx, policy.ActionDelete,
rbac.ResourceOauth2AppCodeToken.WithOwner(arg.UserID.String())); err != nil {
Expand Down
9 changes: 9 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6101,6 +6101,15 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppCodes() {
})
check.Args(code.ID).Asserts(code, policy.ActionDelete)
}))
s.Run("DeleteOAuth2ProviderAppCodeByIDReturningRow", s.Subtest(func(db database.Store, check *expects) {
user := dbgen.User(s.T(), db, database.User{})
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
code := dbgen.OAuth2ProviderAppCode(s.T(), db, database.OAuth2ProviderAppCode{
AppID: app.ID,
UserID: user.ID,
})
check.Args(code.ID).Asserts(code, policy.ActionDelete).Returns(code)
}))
s.Run("DeleteOAuth2ProviderAppCodesByAppAndUserID", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
user := dbgen.User(s.T(), db, database.User{})
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.

29 changes: 29 additions & 0 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19061,6 +19061,35 @@ func TestOAuth2ProviderScopeNotEmpty(t *testing.T) {
})
}

func TestSingleUseDeleteByIDReturningRow(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}

// Callers rely on this delete to arbitrate single use, so a delete that
// removed nothing must report sql.ErrNoRows rather than succeed.
t.Run("OAuth2ProviderAppCode", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)

user := dbgen.User(t, db, database.User{})
app := dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{})
code := dbgen.OAuth2ProviderAppCode(t, db, database.OAuth2ProviderAppCode{
AppID: app.ID,
UserID: user.ID,
})

deleted, err := db.DeleteOAuth2ProviderAppCodeByIDReturningRow(ctx, code.ID)
require.NoError(t, err)
require.Equal(t, code, deleted)

_, err = db.DeleteOAuth2ProviderAppCodeByIDReturningRow(ctx, code.ID)
require.ErrorIs(t, err, sql.ErrNoRows)
})
}

func TestGetAIModelPriceByProviderModel(t *testing.T) {
t.Parallel()

Expand Down
27 changes: 27 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.

5 changes: 5 additions & 0 deletions coderd/database/queries/oauth2.sql
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ INSERT INTO oauth2_provider_app_codes (
-- name: DeleteOAuth2ProviderAppCodeByID :exec
DELETE FROM oauth2_provider_app_codes WHERE id = $1;

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

-- name: DeleteOAuth2ProviderAppCodesByAppAndUserID :exec
DELETE FROM oauth2_provider_app_codes WHERE app_id = $1 AND user_id = $2;

Expand Down
7 changes: 6 additions & 1 deletion coderd/oauth2provider/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,12 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog.

err = db.InTx(func(tx database.Store) error {
ctx := dbauthz.As(ctx, actor)
err = tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID)
// The delete decides the race: only the redemption that removes the row
// may mint a token, and the loser sees the code as already spent.
_, err = tx.DeleteOAuth2ProviderAppCodeByIDReturningRow(ctx, dbCode.ID)
if errors.Is(err, sql.ErrNoRows) {
return errBadCode
}
if err != nil {
return xerrors.Errorf("delete oauth2 app code: %w", err)
}
Expand Down
52 changes: 52 additions & 0 deletions coderd/oauth2provider/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -218,6 +219,57 @@ func TestOAuth2TokenExchangeScope(t *testing.T) {
})
}

// The redemptions race rather than run in sequence: a sequential pair passes
// whether or not the delete arbitrates single use.
func TestOAuth2TokenExchangeSingleUse(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")
form := tokenExchangeForm(app, code, verifier)

type exchange struct {
status int
body string
}

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

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

var minted, rejected int
for _, result := range results {
switch result.status {
case http.StatusOK:
minted++
case http.StatusBadRequest:
require.Contains(t, result.body, string(codersdk.OAuth2ErrorCodeInvalidGrant), result.body)
rejected++
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)
}

// appWithSecret is seeded directly because the management API registers no
// scope allowlist, and the allowlist is what these tests turn.
type appWithSecret struct {
Expand Down
Loading