Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
a672066
feat(coderd/rbac): compare scopes by permission coverage
BobbyHo Aug 14, 2026
4315706
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 17, 2026
787c461
docs(coderd/rbac): shorten the ScopesCover doc comment
BobbyHo Aug 17, 2026
2d39e04
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 18, 2026
2f6c44e
fix(coderd/rbac): guard allowed-side org and user permissions
BobbyHo Aug 18, 2026
bd40270
refactor(coderd/rbac): drop the unreachable negative skip in coverage
BobbyHo Aug 18, 2026
3139c54
test(coderd/rbac): pin the scope coverage table's weak assertions
BobbyHo Aug 18, 2026
9276bb8
refactor(coderd/rbac): make the coverage guards reachable from tests
BobbyHo Aug 18, 2026
865eb9a
refactor(coderd/rbac): share the alias table and name the canonical c…
BobbyHo Aug 18, 2026
26a6bed
docs(coderd/rbac): document the expansion invariant where it can be b…
BobbyHo Aug 18, 2026
7cca7b3
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 19, 2026
1678a77
test(coderd/rbac): pin the coverage guards at one strength
BobbyHo Aug 19, 2026
aba3c6c
fix(coderd/rbac): name the scope once in expansion errors
BobbyHo Aug 19, 2026
9a08105
docs(coderd/rbac): correct the external scope list contract
BobbyHo Aug 19, 2026
2fcce8b
docs(coderd/rbac): trim the restated coverage invariant
BobbyHo Aug 19, 2026
a775a48
docs(coderd/rbac): correct the negative permission cross-reference
BobbyHo Aug 19, 2026
e0c0d4a
docs(coderd/rbac): name every category IsExternalScope admits
BobbyHo Aug 19, 2026
189740d
test(coderd/rbac): pin the alias list invariants on the alias table
BobbyHo Aug 19, 2026
7d08e49
Merge branch 'main' into plat479-1-rbac-scope-coverage
BobbyHo Aug 19, 2026
ca4dc52
feat(coderd/oauth2provider): negotiate and persist authorization scope
BobbyHo Aug 14, 2026
f402e1a
feat(site): state the negotiated scope on the OAuth2 consent page
BobbyHo Aug 14, 2026
28ef5ba
fix(coderd/oauth2provider): return invalid_scope to the client's call…
BobbyHo Aug 14, 2026
62950d3
test(coderd/rbac): close two mutation gaps in scope coverage tests
BobbyHo Aug 20, 2026
554a703
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 20, 2026
09bc1b4
refactor(coderd/oauth2provider): rename validateRequestedScope to neg…
BobbyHo Aug 21, 2026
6fe6c71
test(coderd/oauth2provider): bind the wire-level scope reasons to the…
BobbyHo Aug 21, 2026
e296dda
refactor(coderd): log undecidable oauth2 scope coverage instead of re…
BobbyHo Aug 21, 2026
2760db5
fix(coderd/oauth2provider): name the stored allowlist when none of it…
BobbyHo Aug 21, 2026
5b2ca57
refactor(coderd/oauth2provider): reword the oauth2 scope rejection re…
BobbyHo Aug 21, 2026
90a6f83
docs: correct what the oauth2 authorize scope parameter promises
BobbyHo Aug 21, 2026
77654a1
test(coderd/oauth2provider): fold the no-allowlist guarantee into one…
BobbyHo Aug 21, 2026
dfdd33f
Merge branch 'plat479-2-negotiate-scope' into plat479-3-report-negoti…
BobbyHo Aug 22, 2026
ba8a123
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 22, 2026
bc40908
test(coderd): close the consent-page and state-echo test gaps
BobbyHo Aug 23, 2026
cb7df2e
fix(site/static): left-align the consent permission list
BobbyHo Aug 23, 2026
41e9f40
fix(coderd/oauth2provider): validate the callback scheme before any r…
BobbyHo Aug 23, 2026
a56cdac
fix(coderd/oauth2provider): replace callback query params instead of …
BobbyHo Aug 23, 2026
009962c
feat: say that the consent permission names are technical
BobbyHo Aug 23, 2026
f983335
fix: state unrestricted OAuth2 consent in its own field
BobbyHo Aug 23, 2026
81bbfb0
docs: trim OAuth2 scope negotiation comments
BobbyHo Aug 23, 2026
b7f88f0
Merge branch 'plat479-2-negotiate-scope' into plat479-3-report-negoti…
BobbyHo Aug 23, 2026
2a2e006
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 23, 2026
a11d42c
test(coderd/oauth2provider): pin the consent list semantics and the s…
BobbyHo Aug 23, 2026
eb37c2f
test(coderd/oauth2provider): cover the error redirect in the query-pa…
BobbyHo Aug 23, 2026
a5e3f94
fix(coderd/oauth2provider): answer 500 and log on an unusable callbac…
BobbyHo Aug 23, 2026
60cbe59
fix: refuse to render a consent page for a grant with no permissions
BobbyHo Aug 23, 2026
fdc9532
docs: document invalid_scope rejections on the authorize endpoint
BobbyHo Aug 23, 2026
e63ff3a
docs(coderd): trim the scope test comments
BobbyHo Aug 25, 2026
a9ade4a
Merge branch 'plat479-2-negotiate-scope' into plat479-3-report-negoti…
BobbyHo Aug 25, 2026
5589bf6
docs: trim the consent page and error redirect comments
BobbyHo Aug 25, 2026
24b4f46
docs(coderd): drop the godoc-style comments from the scope tests
BobbyHo Aug 25, 2026
b92b7f9
docs(coderd/oauth2provider): trim the authorize.go comments
BobbyHo Aug 25, 2026
e4cc41d
Merge branch 'plat479-2-negotiate-scope' into plat479-3-report-negoti…
BobbyHo Aug 25, 2026
20caa9c
docs(coderd): trim the scope reporting test comments
BobbyHo Aug 25, 2026
c7d3d53
docs(site): trim the consent page scope comments
BobbyHo Aug 25, 2026
98545a6
feat(coderd): mint OAuth2 tokens with the negotiated scope
BobbyHo Aug 15, 2026
641e45e
test(coderd/oauth2provider): cover the negotiated scope end to end
BobbyHo Aug 18, 2026
1a30a9c
feat(coderd/oauth2provider): report the granted scope in token responses
BobbyHo Aug 18, 2026
8b46823
refactor(coderd/oauth2provider): clarify the unmintable scope sentinel
BobbyHo Aug 18, 2026
da5b590
test(coderd/oauth2provider): derive the exchange query from authorize…
BobbyHo Aug 18, 2026
fc6eb77
docs: scope the OAuth2 refresh limitation to narrowing
BobbyHo Aug 18, 2026
589b8b8
docs(coderd): trim the negotiated scope comments
BobbyHo Aug 25, 2026
78fbab1
Merge branch 'main' into plat479-2-negotiate-scope
BobbyHo Aug 26, 2026
f675cf1
fix(site/static): make the whole consent page reachable by scrolling
BobbyHo Aug 27, 2026
6b5f01d
Merge branch 'plat479-3-report-negotiated-scope' into plat480-1-apply…
BobbyHo Aug 27, 2026
90821d8
docs: say the negotiated scope now bounds the issued token
BobbyHo Aug 27, 2026
72b8cb5
docs: annotate the token endpoint with the response it returns
BobbyHo Aug 27, 2026
b6f74c3
feat: re-check the app allowlist at OAuth2 code redemption
BobbyHo Aug 28, 2026
9f3193e
docs(coderd/oauth2provider): trim the allowlist re-check comments
BobbyHo Aug 28, 2026
9e98cef
fix(coderd): make OAuth2 code redemption single-use under concurrency
BobbyHo Aug 28, 2026
48cb5a4
feat: let an OAuth2 refresh narrow the granted scope
BobbyHo Aug 29, 2026
ceb3997
Update coderd/oauth2provider/authorize.go
BobbyHo Aug 31, 2026
2cd9b60
Update coderd/oauth2provider/authorize.go
BobbyHo Aug 31, 2026
d160bda
docs(coderd/oauth2provider): trim the errNoGrantableScope comment
BobbyHo Aug 31, 2026
b60eee6
refactor(coderd/oauth2provider): canonicalize scopes before the catal…
BobbyHo Aug 31, 2026
4732d1f
refactor(coderd/oauth2provider): canonicalize the allowlist in one pass
BobbyHo Aug 31, 2026
03df6a9
test(coderd/rbac): pin the field sets scope coverage reads
BobbyHo Aug 31, 2026
a2d2856
docs(coderd/rbac): trim the TestCoverageModelFields comment
BobbyHo Aug 31, 2026
d83636b
Merge branch 'plat479-2-negotiate-scope' into plat479-3-report-negoti…
BobbyHo Aug 31, 2026
037e744
Merge remote-tracking branch 'origin/main' into plat479-3-report-nego…
BobbyHo Aug 31, 2026
cd35b94
Merge branch 'plat479-3-report-negotiated-scope' into plat480-1-apply…
BobbyHo Aug 31, 2026
508d09e
Merge branch 'plat480-1-apply-negotiated-scope' into plat480-2-stale-…
BobbyHo Aug 31, 2026
fc40ac3
Merge branch 'plat480-2-stale-scope-recheck' into plat480-3-single-us…
BobbyHo Aug 31, 2026
0e2bf76
Merge branch 'plat480-3-single-use-code' into plat481-1-narrow-refres…
BobbyHo Aug 31, 2026
433e449
docs(coderd/oauth2provider): trim scope negotiation comments
BobbyHo Aug 31, 2026
5dffb74
Merge branch 'plat479-3-report-negotiated-scope' into plat480-1-apply…
BobbyHo Aug 31, 2026
6aa7510
docs(coderd/oauth2provider): trim scope negotiation comments
BobbyHo Aug 31, 2026
a1eae58
Merge branch 'plat480-1-apply-negotiated-scope' into plat480-2-stale-…
BobbyHo Aug 31, 2026
e1ea400
docs(coderd/oauth2provider): trim scope negotiation comments
BobbyHo Aug 31, 2026
52567a4
Merge branch 'plat480-2-stale-scope-recheck' into plat480-3-single-us…
BobbyHo Aug 31, 2026
8efc783
docs(coderd/oauth2provider): trim the single-use code comment
BobbyHo Aug 31, 2026
124d652
Merge branch 'plat480-3-single-use-code' into plat481-1-narrow-refres…
BobbyHo Aug 31, 2026
5b08b81
docs(coderd/oauth2provider): trim refresh scope comments
BobbyHo Aug 31, 2026
35ce008
Merge branch 'main' into plat479-3-report-negotiated-scope
BobbyHo Aug 31, 2026
48d9c7f
fix(coderd/oauth2provider): return server_error for undecidable scope…
BobbyHo Sep 1, 2026
c8a3754
Merge branch 'plat479-3-report-negotiated-scope' into plat480-1-apply…
BobbyHo Sep 1, 2026
339aa34
Merge branch 'plat480-1-apply-negotiated-scope' into plat480-2-stale-…
BobbyHo Sep 1, 2026
6b29b13
Merge branch 'main' into plat480-1-apply-negotiated-scope
BobbyHo Sep 1, 2026
976335d
fix: answer invalid_grant for an unmintable stored scope
BobbyHo Sep 1, 2026
5ebc706
Merge branch 'plat480-1-apply-negotiated-scope' into plat480-2-stale-…
BobbyHo Sep 1, 2026
4a31118
Merge remote-tracking branch 'origin/main' into plat480-2-stale-scope…
BobbyHo Sep 2, 2026
cf7ddb7
Merge branch 'plat480-2-stale-scope-recheck' into plat480-3-single-us…
BobbyHo Sep 2, 2026
7a251f1
Merge branch 'plat480-3-single-use-code' into plat481-1-narrow-refres…
BobbyHo Sep 2, 2026
c54ccdb
fix(coderd): address the allowlist re-check review
BobbyHo Sep 2, 2026
d5c8a1c
docs(coderd): simplify the scope check comments
BobbyHo Sep 2, 2026
bb574d0
Merge branch 'plat480-2-stale-scope-recheck' into plat480-3-single-us…
BobbyHo Sep 2, 2026
40fda97
Merge branch 'plat480-3-single-use-code' into plat481-1-narrow-refres…
BobbyHo Sep 2, 2026
886ec18
refactor(coderd/oauth2provider): rename the coverage wrapper
BobbyHo Sep 2, 2026
d9d032d
test(coderd): cover replay and post-redemption state for single-use c…
BobbyHo Sep 3, 2026
8acedcd
Merge remote-tracking branch 'origin/main' into plat480-3-single-use-…
BobbyHo Sep 4, 2026
361f669
Merge branch 'plat480-3-single-use-code' into plat481-1-narrow-refres…
BobbyHo Sep 4, 2026
85030f8
docs(coderd/oauth2provider): correct the canonicalization cross-refer…
BobbyHo Sep 4, 2026
ec80733
test(coderd/oauth2provider): assert the winner's token survives a ref…
BobbyHo Sep 4, 2026
98e6191
refactor(coderd): fold the duplicated app code delete into one query
BobbyHo Sep 4, 2026
15cc66f
docs(coderd): say why the code is deleted, and where single use can b…
BobbyHo Sep 4, 2026
b64f111
test(coderd/oauth2provider): enforce the redemption overlap in the test
BobbyHo Sep 4, 2026
270bfb5
Merge branch 'plat480-3-single-use-code' into plat481-1-narrow-refres…
BobbyHo Sep 4, 2026
f644e52
fix(coderd/oauth2provider): bound and sanitize the token endpoint's e…
BobbyHo Sep 4, 2026
663fa85
fix(coderd/oauth2provider): narrow the access token, not the grant
BobbyHo Sep 4, 2026
8f87b2d
fix(coderd/oauth2provider): name the way out when a refresh cannot widen
BobbyHo Sep 5, 2026
f54bbbc
fix(coderd/oauth2provider): canonicalize the ceiling once in narrowAc…
BobbyHo Sep 5, 2026
8d5f168
refactor(coderd/oauth2provider): address the review's nits and notes
BobbyHo Sep 5, 2026
e1b4f06
docs(admin/integrations): file the refresh narrowing under the code i…
BobbyHo Sep 5, 2026
2ba380a
test(coderd/oauth2provider): assert through the endpoint, not around it
BobbyHo Sep 5, 2026
755b5a1
fix(coderd/oauth2provider): honour scope on the exchange, and log sco…
BobbyHo Sep 5, 2026
e1f3f75
docs(coderd/oauth2provider): trim the scope comments
BobbyHo Sep 6, 2026
4243eb4
fix(coderd): make OAuth2 refresh token redemption single-use under co…
BobbyHo Aug 29, 2026
d15dca4
docs(coderd/oauth2provider): trim the single-use refresh comment
BobbyHo Aug 31, 2026
b94b304
test(coderd/oauth2provider): share the single-use race harness with t…
BobbyHo Sep 6, 2026
b6018df
refactor(coderd/oauth2provider): declare the refresh delete error, do…
BobbyHo Sep 6, 2026
1c3f276
test(coderd/oauth2provider): pin the refresh race at the token read
BobbyHo Sep 6, 2026
81bad9e
docs(coderd/oauth2provider): say plainly why the refresh delete is in…
BobbyHo Sep 6, 2026
8fc16f0
fix(coderd/oauth2provider): let the refresh delete alone arbitrate th…
BobbyHo Sep 6, 2026
210409d
test(coderd/oauth2provider): pin invalid_grant for a refresh whose ke…
BobbyHo Aug 29, 2026
9a7fba4
fix(coderd/oauth2provider): stop echoing a scope that names nothing
BobbyHo Sep 5, 2026
c8d07ad
fix(coderd/oauth2provider): pin READ COMMITTED for the single-use del…
BobbyHo Sep 6, 2026
b55edf3
fix(coderd/oauth2provider): log a refused reuse of a code or refresh …
BobbyHo Sep 6, 2026
17cc715
docs(coderd/database): give the returning-row key delete the same doc…
BobbyHo Sep 6, 2026
7abfb3f
test(coderd/database/dbauthz): pin that a missed fetch still matches …
BobbyHo Sep 6, 2026
9510422
test(coderd/oauth2provider): bound the race barrier and check the acc…
BobbyHo Sep 6, 2026
8d1c4f8
Merge remote-tracking branch 'origin/main' into plat481-1-narrow-refr…
BobbyHo Sep 9, 2026
283cad4
Merge branch 'plat481-1-narrow-refresh-scope' into plat481-2-single-u…
BobbyHo Sep 9, 2026
017c725
Merge remote-tracking branch 'origin/main' into plat481-2-single-use-…
BobbyHo Sep 9, 2026
dbdff37
Merge branch 'main' into plat481-2-single-use-refresh
BobbyHo Sep 9, 2026
fefddb5
Merge branch 'main' into plat481-2-single-use-refresh
BobbyHo Sep 9, 2026
2bacd8c
Merge branch 'main' into plat481-2-single-use-refresh
BobbyHo Sep 10, 2026
1cebdf7
Merge branch 'main' into plat481-2-single-use-refresh
BobbyHo Sep 10, 2026
12e30c2
Merge branch 'main' into plat481-2-single-use-refresh
BobbyHo Sep 11, 2026
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 @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Comment thread
BobbyHo marked this conversation as resolved.
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.

8 changes: 8 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 @@ -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) {
Expand Down
36 changes: 36 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.

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

-- name: DeleteAPIKeyByIDReturningRow :one
Comment thread
BobbyHo marked this conversation as resolved.
-- 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
Expand Down
46 changes: 32 additions & 14 deletions coderd/oauth2provider/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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,
Expand All @@ -735,7 +742,18 @@ func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logge

err = db.InTx(func(tx database.Store) error {
Comment thread
BobbyHo marked this conversation as resolved.
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) {
Comment thread
BobbyHo marked this conversation as resolved.
// 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
Comment thread
BobbyHo marked this conversation as resolved.
}
if err != nil {
return xerrors.Errorf("delete oauth2 app token: %w", err)
}
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 8 additions & 1 deletion coderd/oauth2provider/tokens_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
Expand Down
Loading
Loading