Skip to content

Commit 6cf3ccb

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 a9550d8 commit 6cf3ccb

File tree

15 files changed

+834
-7
lines changed

15 files changed

+834
-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/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: 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/dbauthz_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5370,6 +5370,21 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() {
53705370
})
53715371
check.Args(token.HashPrefix).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionRead)
53725372
}))
5373+
s.Run("GetOAuth2ProviderAppTokenByAPIKeyID", s.Subtest(func(db database.Store, check *expects) {
5374+
user := dbgen.User(s.T(), db, database.User{})
5375+
key, _ := dbgen.APIKey(s.T(), db, database.APIKey{
5376+
UserID: user.ID,
5377+
})
5378+
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
5379+
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
5380+
AppID: app.ID,
5381+
})
5382+
token := dbgen.OAuth2ProviderAppToken(s.T(), db, database.OAuth2ProviderAppToken{
5383+
AppSecretID: secret.ID,
5384+
APIKeyID: key.ID,
5385+
})
5386+
check.Args(token.APIKeyID).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionRead).Returns(token)
5387+
}))
53735388
s.Run("DeleteOAuth2ProviderAppTokensByAppAndUserID", s.Subtest(func(db database.Store, check *expects) {
53745389
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
53755390
user := dbgen.User(s.T(), db, database.User{})

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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"golang.org/x/oauth2"
1919
"golang.org/x/xerrors"
2020

21+
"cdr.dev/slog"
2122
"github.com/coder/coder/v2/coderd/database"
2223
"github.com/coder/coder/v2/coderd/database/dbauthz"
2324
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -110,6 +111,9 @@ type ExtractAPIKeyConfig struct {
110111
// This is originally implemented to send entitlement warning headers after
111112
// a user is authenticated to prevent additional CLI invocations.
112113
PostAuthAdditionalHeadersFunc func(a rbac.Subject, header http.Header)
114+
115+
// Logger is used for logging middleware operations.
116+
Logger slog.Logger
113117
}
114118

115119
// ExtractAPIKeyMW calls ExtractAPIKey with the given config on each request,
@@ -240,6 +244,17 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
240244
})
241245
}
242246

247+
// Validate OAuth2 provider app token audience (RFC 8707) if applicable
248+
if key.LoginType == database.LoginTypeOAuth2ProviderApp {
249+
if err := validateOAuth2ProviderAppTokenAudience(ctx, cfg.DB, *key, r); err != nil {
250+
// Log the detailed error for debugging but don't expose it to the client
251+
cfg.Logger.Info(ctx, "oauth2 token audience validation failed", slog.Error(err))
252+
return optionalWrite(http.StatusForbidden, codersdk.Response{
253+
Message: "Token audience validation failed",
254+
})
255+
}
256+
}
257+
243258
// We only check OIDC stuff if we have a valid APIKey. An expired key means we don't trust the requestor
244259
// really is the user whose key they have, and so we shouldn't be doing anything on their behalf including possibly
245260
// refreshing the OIDC token.
@@ -446,6 +461,115 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
446461
return key, &actor, true
447462
}
448463

464+
// validateOAuth2ProviderAppTokenAudience validates that an OAuth2 provider app token
465+
// is being used with the correct audience/resource server (RFC 8707).
466+
func validateOAuth2ProviderAppTokenAudience(ctx context.Context, db database.Store, key database.APIKey, r *http.Request) error {
467+
// Get the OAuth2 provider app token to check its audience
468+
//nolint:gocritic // System needs to access token for audience validation
469+
token, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemRestricted(ctx), key.ID)
470+
if err != nil {
471+
return xerrors.Errorf("failed to get OAuth2 token: %w", err)
472+
}
473+
474+
// If no audience is set, allow the request (for backward compatibility)
475+
if !token.Audience.Valid || token.Audience.String == "" {
476+
return nil
477+
}
478+
479+
// Extract the expected audience from the request
480+
expectedAudience := extractExpectedAudience(r)
481+
482+
// Normalize both audience values for RFC 3986 compliant comparison
483+
normalizedTokenAudience := normalizeAudienceURI(token.Audience.String)
484+
normalizedExpectedAudience := normalizeAudienceURI(expectedAudience)
485+
486+
// Validate that the token's audience matches the expected audience
487+
if normalizedTokenAudience != normalizedExpectedAudience {
488+
return xerrors.Errorf("token audience %q does not match expected audience %q",
489+
token.Audience.String, expectedAudience)
490+
}
491+
492+
return nil
493+
}
494+
495+
// normalizeAudienceURI implements RFC 3986 URI normalization for OAuth2 audience comparison.
496+
// This ensures consistent audience matching between authorization and token validation.
497+
func normalizeAudienceURI(audienceURI string) string {
498+
if audienceURI == "" {
499+
return ""
500+
}
501+
502+
u, err := url.Parse(audienceURI)
503+
if err != nil {
504+
// If parsing fails, return as-is to avoid breaking existing functionality
505+
return audienceURI
506+
}
507+
508+
// Apply RFC 3986 syntax-based normalization:
509+
510+
// 1. Scheme and host are case-insensitive
511+
u.Scheme = strings.ToLower(u.Scheme)
512+
u.Host = strings.ToLower(u.Host)
513+
514+
// 2. Remove default ports for HTTP/HTTPS
515+
if (u.Scheme == "http" && strings.HasSuffix(u.Host, ":80")) ||
516+
(u.Scheme == "https" && strings.HasSuffix(u.Host, ":443")) {
517+
// Extract host without default port
518+
if idx := strings.LastIndex(u.Host, ":"); idx > 0 {
519+
u.Host = u.Host[:idx]
520+
}
521+
}
522+
523+
// 3. Path normalization:
524+
// - For OAuth2 audience URIs, we normalize trailing slashes for consistency
525+
// - RFC 8707 recommends specific URIs, so trailing slash handling is important
526+
// - According to RFC 3986, paths are case-sensitive, so we don't change case
527+
if u.Path == "" {
528+
// If no path is specified, use "/" for consistency with RFC 8707 examples
529+
u.Path = "/"
530+
}
531+
// Remove trailing slash from paths longer than "/" to normalize
532+
if len(u.Path) > 1 && strings.HasSuffix(u.Path, "/") {
533+
u.Path = strings.TrimSuffix(u.Path, "/")
534+
}
535+
536+
// 4. Remove fragment (OAuth2 audience should not have fragments anyway)
537+
u.Fragment = ""
538+
539+
// 5. Keep query parameters as-is (rarely used in audience URIs but preserved for compatibility)
540+
541+
return u.String()
542+
}
543+
544+
// Test export functions for testing package access
545+
546+
// NormalizeAudienceURIForTest exports normalizeAudienceURI for testing
547+
func NormalizeAudienceURIForTest(uri string) string {
548+
return normalizeAudienceURI(uri)
549+
}
550+
551+
// ExtractExpectedAudienceForTest exports extractExpectedAudience for testing
552+
func ExtractExpectedAudienceForTest(r *http.Request) string {
553+
return extractExpectedAudience(r)
554+
}
555+
556+
// extractExpectedAudience determines the expected audience for the current request.
557+
// This should match the resource parameter used during authorization.
558+
func extractExpectedAudience(r *http.Request) string {
559+
// For MCP compliance, the audience should be the canonical URI of the resource server
560+
// This typically matches the access URL of the Coder deployment
561+
scheme := "https"
562+
if r.TLS == nil {
563+
scheme = "http"
564+
}
565+
566+
// Use the Host header to construct the canonical audience URI
567+
audience := fmt.Sprintf("%s://%s", scheme, r.Host)
568+
569+
// Normalize the URI according to RFC 3986 for consistent comparison
570+
return normalizeAudienceURI(audience)
571+
}
572+
449573
// UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both
450574
// site and organization scopes. It also pulls the groups, and the user's status.
451575
func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scope rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) {

0 commit comments

Comments
 (0)