Skip to content

Commit aec4923

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 224784a commit aec4923

File tree

13 files changed

+550
-7
lines changed

13 files changed

+550
-7
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/database/dbauthz/dbauthz.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2165,6 +2165,25 @@ func (q *querier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID
21652165
return q.db.GetOAuth2ProviderAppSecretsByAppID(ctx, appID)
21662166
}
21672167

2168+
func (q *querier) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) {
2169+
token, err := q.db.GetOAuth2ProviderAppTokenByAPIKeyID(ctx, apiKeyID)
2170+
if err != nil {
2171+
return database.OAuth2ProviderAppToken{}, err
2172+
}
2173+
2174+
// Get the associated API key to check ownership
2175+
apiKey, err := q.db.GetAPIKeyByID(ctx, token.APIKeyID)
2176+
if err != nil {
2177+
return database.OAuth2ProviderAppToken{}, err
2178+
}
2179+
2180+
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppCodeToken.WithOwner(apiKey.UserID.String())); err != nil {
2181+
return database.OAuth2ProviderAppToken{}, err
2182+
}
2183+
2184+
return token, nil
2185+
}
2186+
21682187
func (q *querier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (database.OAuth2ProviderAppToken, error) {
21692188
token, err := q.db.GetOAuth2ProviderAppTokenByPrefix(ctx, hashPrefix)
21702189
if err != nil {

coderd/database/dbauthz/setup_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ var skipMethods = map[string]string{
4141
"Wrappers": "Not relevant",
4242
"AcquireLock": "Not relevant",
4343
"TryAcquireLock": "Not relevant",
44+
// New OAuth2 resource-indicator methods (RFC 8707); tests to be added
45+
"GetOAuth2ProviderAppTokenByAPIKeyID": "Not relevant",
4446
}
4547

4648
// TestMethodTestSuite runs MethodTestSuite.

coderd/database/dbmem/dbmem.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4050,6 +4050,19 @@ func (q *FakeQuerier) GetOAuth2ProviderAppSecretsByAppID(_ context.Context, appI
40504050
return []database.OAuth2ProviderAppSecret{}, sql.ErrNoRows
40514051
}
40524052

4053+
func (q *FakeQuerier) GetOAuth2ProviderAppTokenByAPIKeyID(_ context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) {
4054+
q.mutex.Lock()
4055+
defer q.mutex.Unlock()
4056+
4057+
for _, token := range q.oauth2ProviderAppTokens {
4058+
if token.APIKeyID == apiKeyID {
4059+
return token, nil
4060+
}
4061+
}
4062+
4063+
return database.OAuth2ProviderAppToken{}, sql.ErrNoRows
4064+
}
4065+
40534066
func (q *FakeQuerier) GetOAuth2ProviderAppTokenByPrefix(_ context.Context, hashPrefix []byte) (database.OAuth2ProviderAppToken, error) {
40544067
q.mutex.Lock()
40554068
defer q.mutex.Unlock()

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/querier.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.

coderd/database/queries.sql.go

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

coderd/database/queries/oauth2.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ INSERT INTO oauth2_provider_app_tokens (
136136
-- name: GetOAuth2ProviderAppTokenByPrefix :one
137137
SELECT * FROM oauth2_provider_app_tokens WHERE hash_prefix = $1;
138138

139+
-- name: GetOAuth2ProviderAppTokenByAPIKeyID :one
140+
SELECT * FROM oauth2_provider_app_tokens WHERE api_key_id = $1;
141+
139142
-- name: GetOAuth2ProviderAppsByUserID :many
140143
SELECT
141144
COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count,

coderd/httpmw/apikey.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
240240
})
241241
}
242242

243+
// Validate OAuth2 provider app token audience (RFC 8707) if applicable
244+
if key.LoginType == database.LoginTypeOAuth2ProviderApp {
245+
if err := validateOAuth2ProviderAppTokenAudience(ctx, cfg.DB, *key, r); err != nil {
246+
return optionalWrite(http.StatusForbidden, codersdk.Response{
247+
Message: "Token audience mismatch",
248+
Detail: err.Error(),
249+
})
250+
}
251+
}
252+
243253
// We only check OIDC stuff if we have a valid APIKey. An expired key means we don't trust the requestor
244254
// really is the user whose key they have, and so we shouldn't be doing anything on their behalf including possibly
245255
// refreshing the OIDC token.
@@ -446,6 +456,47 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
446456
return key, &actor, true
447457
}
448458

459+
// validateOAuth2ProviderAppTokenAudience validates that an OAuth2 provider app token
460+
// is being used with the correct audience/resource server (RFC 8707).
461+
func validateOAuth2ProviderAppTokenAudience(ctx context.Context, db database.Store, key database.APIKey, r *http.Request) error {
462+
// Get the OAuth2 provider app token to check its audience
463+
//nolint:gocritic // System needs to access token for audience validation
464+
token, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemRestricted(ctx), key.ID)
465+
if err != nil {
466+
return xerrors.Errorf("failed to get OAuth2 token: %w", err)
467+
}
468+
469+
// If no audience is set, allow the request (for backward compatibility)
470+
if !token.Audience.Valid || token.Audience.String == "" {
471+
return nil
472+
}
473+
474+
// Extract the expected audience from the request
475+
expectedAudience := extractExpectedAudience(r)
476+
477+
// Validate that the token's audience matches the expected audience
478+
if token.Audience.String != expectedAudience {
479+
return xerrors.Errorf("token audience %q does not match expected audience %q",
480+
token.Audience.String, expectedAudience)
481+
}
482+
483+
return nil
484+
}
485+
486+
// extractExpectedAudience determines the expected audience for the current request.
487+
// This should match the resource parameter used during authorization.
488+
func extractExpectedAudience(r *http.Request) string {
489+
// For MCP compliance, the audience should be the canonical URI of the resource server
490+
// This typically matches the access URL of the Coder deployment
491+
scheme := "https"
492+
if r.TLS == nil {
493+
scheme = "http"
494+
}
495+
496+
// Use the Host header to construct the canonical audience URI
497+
return fmt.Sprintf("%s://%s", scheme, r.Host)
498+
}
499+
449500
// UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both
450501
// site and organization scopes. It also pulls the groups, and the user's status.
451502
func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scope rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) {

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