Skip to content

Commit 505c46c

Browse files
committed
feat(oauth2): add RFC 8707 resource indicators and audience validation
Implements RFC 8707 Resource Indicators for OAuth2 provider to enable proper audience validation and token binding for multi-tenant scenarios. Key changes: - Add resource parameter support to authorization and token endpoints - Implement server-side audience validation for opaque tokens - Add database fields: ResourceUri (codes) and Audience (tokens) - Add comprehensive resource parameter validation logic - Add cross-resource audience validation in API middleware - Add extensive test coverage for RFC 8707 scenarios - Enhance PKCE implementation with timing attack protection This enables OAuth2 clients to specify target resource servers and prevents token abuse across different Coder deployments through proper audience binding. Change-Id: I3924cb2139e837e3ac0b0bd40a5aeb59637ebc1b Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent d22ac1c commit 505c46c

22 files changed

+1053
-56
lines changed

CLAUDE.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ Read [cursor rules](.cursorrules).
8989
- Format: `{number}_{description}.{up|down}.sql`
9090
- Number must be unique and sequential
9191
- Always include both up and down migrations
92+
- **Use helper scripts**:
93+
- `./coderd/database/migrations/create_migration.sh "migration name"` - Creates new migration files
94+
- `./coderd/database/migrations/fix_migration_numbers.sh` - Renumbers migrations to avoid conflicts
95+
- `./coderd/database/migrations/create_fixture.sh "fixture name"` - Creates test fixtures for migrations
9296

9397
2. **Update database queries**:
9498
- MUST DO! Any changes to database - adding queries, modifying queries should be done in the `coderd/database/queries/*.sql` files
@@ -125,6 +129,29 @@ Read [cursor rules](.cursorrules).
125129
4. Run `make gen` again
126130
5. Run `make lint` to catch any remaining issues
127131

132+
### In-Memory Database Testing
133+
134+
When adding new database fields:
135+
136+
- **CRITICAL**: Update `coderd/database/dbmem/dbmem.go` in-memory implementations
137+
- The `Insert*` functions must include ALL new fields, not just basic ones
138+
- Common issue: Tests pass with real database but fail with in-memory database due to missing field mappings
139+
- Always verify in-memory database functions match the real database schema after migrations
140+
141+
Example pattern:
142+
143+
```go
144+
// In dbmem.go - ensure ALL fields are included
145+
code := database.OAuth2ProviderAppCode{
146+
ID: arg.ID,
147+
CreatedAt: arg.CreatedAt,
148+
// ... existing fields ...
149+
ResourceUri: arg.ResourceUri, // New field
150+
CodeChallenge: arg.CodeChallenge, // New field
151+
CodeChallengeMethod: arg.CodeChallengeMethod, // New field
152+
}
153+
```
154+
128155
## Architecture
129156

130157
### Core Components
@@ -209,6 +236,12 @@ When working on OAuth2 provider features:
209236
- Avoid dependency on referer headers for security decisions
210237
- Support proper state parameter validation
211238

239+
6. **RFC 8707 Resource Indicators**:
240+
- Store resource parameters in database for server-side validation (opaque tokens)
241+
- Validate resource consistency between authorization and token requests
242+
- Support audience validation in refresh token flows
243+
- Resource parameter is optional but must be consistent when provided
244+
212245
### OAuth2 Error Handling Pattern
213246

214247
```go
@@ -265,3 +298,6 @@ Always run the full test suite after OAuth2 changes:
265298
4. **Missing newlines** - Ensure files end with newline character
266299
5. **Tests passing locally but failing in CI** - Check if `dbmem` implementation needs updating
267300
6. **OAuth2 endpoints returning wrong error format** - Ensure OAuth2 endpoints return RFC 6749 compliant errors
301+
7. **OAuth2 tests failing but scripts working** - Check in-memory database implementations in `dbmem.go`
302+
8. **Resource indicator validation failing** - Ensure database stores and retrieves resource parameters correctly
303+
9. **PKCE tests failing** - Verify both authorization code storage and token exchange handle PKCE fields

coderd/coderd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,7 @@ func New(options *Options) *API {
781781
Optional: false,
782782
SessionTokenFunc: nil, // Default behavior
783783
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
784+
Logger: options.Logger,
784785
})
785786
// Same as above but it redirects to the login page.
786787
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
@@ -791,6 +792,7 @@ func New(options *Options) *API {
791792
Optional: false,
792793
SessionTokenFunc: nil, // Default behavior
793794
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
795+
Logger: options.Logger,
794796
})
795797
// Same as the first but it's optional.
796798
apiKeyMiddlewareOptional := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
@@ -801,6 +803,7 @@ func New(options *Options) *API {
801803
Optional: true,
802804
SessionTokenFunc: nil, // Default behavior
803805
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
806+
Logger: options.Logger,
804807
})
805808

806809
workspaceAgentInfo := httpmw.ExtractWorkspaceAgentAndLatestBuild(httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{

coderd/database/dbauthz/dbauthz.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2181,19 +2181,29 @@ func (q *querier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID
21812181
return q.db.GetOAuth2ProviderAppSecretsByAppID(ctx, appID)
21822182
}
21832183

2184-
func (q *querier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (database.OAuth2ProviderAppToken, error) {
2185-
token, err := q.db.GetOAuth2ProviderAppTokenByPrefix(ctx, hashPrefix)
2184+
func (q *querier) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) {
2185+
token, err := q.db.GetOAuth2ProviderAppTokenByAPIKeyID(ctx, apiKeyID)
21862186
if err != nil {
21872187
return database.OAuth2ProviderAppToken{}, err
21882188
}
2189-
// The user ID is on the API key so that has to be fetched.
2190-
key, err := q.db.GetAPIKeyByID(ctx, token.APIKeyID)
2189+
2190+
if err := q.authorizeContext(ctx, policy.ActionRead, token.RBACObject()); err != nil {
2191+
return database.OAuth2ProviderAppToken{}, err
2192+
}
2193+
2194+
return token, nil
2195+
}
2196+
2197+
func (q *querier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (database.OAuth2ProviderAppToken, error) {
2198+
token, err := q.db.GetOAuth2ProviderAppTokenByPrefix(ctx, hashPrefix)
21912199
if err != nil {
21922200
return database.OAuth2ProviderAppToken{}, err
21932201
}
2194-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppCodeToken.WithOwner(key.UserID.String())); err != nil {
2202+
2203+
if err := q.authorizeContext(ctx, policy.ActionRead, token.RBACObject()); err != nil {
21952204
return database.OAuth2ProviderAppToken{}, err
21962205
}
2206+
21972207
return token, nil
21982208
}
21992209

@@ -3646,11 +3656,7 @@ func (q *querier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg databas
36463656
}
36473657

36483658
func (q *querier) InsertOAuth2ProviderAppToken(ctx context.Context, arg database.InsertOAuth2ProviderAppTokenParams) (database.OAuth2ProviderAppToken, error) {
3649-
key, err := q.db.GetAPIKeyByID(ctx, arg.APIKeyID)
3650-
if err != nil {
3651-
return database.OAuth2ProviderAppToken{}, err
3652-
}
3653-
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2AppCodeToken.WithOwner(key.UserID.String())); err != nil {
3659+
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2AppCodeToken.WithOwner(arg.UserID.String())); err != nil {
36543660
return database.OAuth2ProviderAppToken{}, err
36553661
}
36563662
return q.db.InsertOAuth2ProviderAppToken(ctx, arg)

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5199,8 +5199,6 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() {
51995199
})
52005200
}
52015201
expectedApp := app
5202-
expectedApp.CreatedAt = createdAt
5203-
expectedApp.UpdatedAt = createdAt
52045202
check.Args(user.ID).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionRead).Returns([]database.GetOAuth2ProviderAppsByUserIDRow{
52055203
{
52065204
OAuth2ProviderApp: expectedApp,
@@ -5363,6 +5361,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() {
53635361
check.Args(database.InsertOAuth2ProviderAppTokenParams{
53645362
AppSecretID: secret.ID,
53655363
APIKeyID: key.ID,
5364+
UserID: user.ID,
53665365
}).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionCreate)
53675366
}))
53685367
s.Run("GetOAuth2ProviderAppTokenByPrefix", s.Subtest(func(db database.Store, check *expects) {
@@ -5377,8 +5376,25 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() {
53775376
token := dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{
53785377
AppSecretID: secret.ID,
53795378
APIKeyID: key.ID,
5379+
UserID: user.ID,
53805380
})
5381-
check.Args(token.HashPrefix).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionRead)
5381+
check.Args(token.HashPrefix).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()).WithID(token.ID), policy.ActionRead).Returns(token)
5382+
}))
5383+
s.Run("GetOAuth2ProviderAppTokenByAPIKeyID", s.Subtest(func(db database.Store, check *expects) {
5384+
user := dbgen.User(s.T(), db, database.User{})
5385+
key, _ := dbgen.APIKey(s.T(), db, database.APIKey{
5386+
UserID: user.ID,
5387+
})
5388+
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
5389+
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
5390+
AppID: app.ID,
5391+
})
5392+
token := dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{
5393+
AppSecretID: secret.ID,
5394+
APIKeyID: key.ID,
5395+
UserID: user.ID,
5396+
})
5397+
check.Args(token.APIKeyID).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()).WithID(token.ID), policy.ActionRead).Returns(token)
53825398
}))
53835399
s.Run("DeleteOAuth2ProviderAppTokensByAppAndUserID", s.Subtest(func(db database.Store, check *expects) {
53845400
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
@@ -5394,6 +5410,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() {
53945410
_ = dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{
53955411
AppSecretID: secret.ID,
53965412
APIKeyID: key.ID,
5413+
UserID: user.ID,
53975414
HashPrefix: []byte(fmt.Sprintf("%d", i)),
53985415
})
53995416
}

coderd/database/dbgen/dbgen.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,7 @@ func OAuth2ProviderAppToken(t testing.TB, db database.Store, seed database.OAuth
11901190
RefreshHash: takeFirstSlice(seed.RefreshHash, []byte("hashed-secret")),
11911191
AppSecretID: takeFirst(seed.AppSecretID, uuid.New()),
11921192
APIKeyID: takeFirst(seed.APIKeyID, uuid.New().String()),
1193+
UserID: takeFirst(seed.UserID, uuid.New()),
11931194
Audience: seed.Audience,
11941195
})
11951196
require.NoError(t, err, "insert oauth2 app token")

coderd/database/dbmem/dbmem.go

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4054,6 +4054,19 @@ func (q *FakeQuerier) GetOAuth2ProviderAppSecretsByAppID(_ context.Context, appI
40544054
return []database.OAuth2ProviderAppSecret{}, sql.ErrNoRows
40554055
}
40564056

4057+
func (q *FakeQuerier) GetOAuth2ProviderAppTokenByAPIKeyID(_ context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) {
4058+
q.mutex.Lock()
4059+
defer q.mutex.Unlock()
4060+
4061+
for _, token := range q.oauth2ProviderAppTokens {
4062+
if token.APIKeyID == apiKeyID {
4063+
return token, nil
4064+
}
4065+
}
4066+
4067+
return database.OAuth2ProviderAppToken{}, sql.ErrNoRows
4068+
}
4069+
40574070
func (q *FakeQuerier) GetOAuth2ProviderAppTokenByPrefix(_ context.Context, hashPrefix []byte) (database.OAuth2ProviderAppToken, error) {
40584071
q.mutex.Lock()
40594072
defer q.mutex.Unlock()
@@ -4099,13 +4112,8 @@ func (q *FakeQuerier) GetOAuth2ProviderAppsByUserID(_ context.Context, userID uu
40994112
}
41004113
if len(tokens) > 0 {
41014114
rows = append(rows, database.GetOAuth2ProviderAppsByUserIDRow{
4102-
OAuth2ProviderApp: database.OAuth2ProviderApp{
4103-
CallbackURL: app.CallbackURL,
4104-
ID: app.ID,
4105-
Icon: app.Icon,
4106-
Name: app.Name,
4107-
},
4108-
TokenCount: int64(len(tokens)),
4115+
OAuth2ProviderApp: app,
4116+
TokenCount: int64(len(tokens)),
41094117
})
41104118
}
41114119
}
@@ -8918,12 +8926,15 @@ func (q *FakeQuerier) InsertOAuth2ProviderApp(_ context.Context, arg database.In
89188926

89198927
//nolint:gosimple // Go wants database.OAuth2ProviderApp(arg), but we cannot be sure the structs will remain identical.
89208928
app := database.OAuth2ProviderApp{
8921-
ID: arg.ID,
8922-
CreatedAt: arg.CreatedAt,
8923-
UpdatedAt: arg.UpdatedAt,
8924-
Name: arg.Name,
8925-
Icon: arg.Icon,
8926-
CallbackURL: arg.CallbackURL,
8929+
ID: arg.ID,
8930+
CreatedAt: arg.CreatedAt,
8931+
UpdatedAt: arg.UpdatedAt,
8932+
Name: arg.Name,
8933+
Icon: arg.Icon,
8934+
CallbackURL: arg.CallbackURL,
8935+
RedirectUris: arg.RedirectUris,
8936+
ClientType: arg.ClientType,
8937+
DynamicallyRegistered: arg.DynamicallyRegistered,
89278938
}
89288939
q.oauth2ProviderApps = append(q.oauth2ProviderApps, app)
89298940

@@ -9008,6 +9019,8 @@ func (q *FakeQuerier) InsertOAuth2ProviderAppToken(_ context.Context, arg databa
90089019
RefreshHash: arg.RefreshHash,
90099020
APIKeyID: arg.APIKeyID,
90109021
AppSecretID: arg.AppSecretID,
9022+
UserID: arg.UserID,
9023+
Audience: arg.Audience,
90119024
}
90129025
q.oauth2ProviderAppTokens = append(q.oauth2ProviderAppTokens, token)
90139026
return token, nil
@@ -10790,12 +10803,15 @@ func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg databas
1079010803
for index, app := range q.oauth2ProviderApps {
1079110804
if app.ID == arg.ID {
1079210805
newApp := database.OAuth2ProviderApp{
10793-
ID: arg.ID,
10794-
CreatedAt: app.CreatedAt,
10795-
UpdatedAt: arg.UpdatedAt,
10796-
Name: arg.Name,
10797-
Icon: arg.Icon,
10798-
CallbackURL: arg.CallbackURL,
10806+
ID: arg.ID,
10807+
CreatedAt: app.CreatedAt,
10808+
UpdatedAt: arg.UpdatedAt,
10809+
Name: arg.Name,
10810+
Icon: arg.Icon,
10811+
CallbackURL: arg.CallbackURL,
10812+
RedirectUris: arg.RedirectUris,
10813+
ClientType: arg.ClientType,
10814+
DynamicallyRegistered: arg.DynamicallyRegistered,
1079910815
}
1080010816
q.oauth2ProviderApps[index] = newApp
1080110817
return newApp, nil

coderd/database/dbmetrics/querymetrics.go

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dump.sql

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/foreign_key_constraint.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy