Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat: oauth2 - add RFC 8707 resource indicators and audience validation#18575

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Open
ThomasK33 wants to merge1 commit intothomask33/06-24-feat_oauth2_add_authorization_server_metadata_endpoint_and_pkce_support
base:thomask33/06-24-feat_oauth2_add_authorization_server_metadata_endpoint_and_pkce_support
Choose a base branch
Loading
fromthomask33/06-25-feat_oauth2_add_rfc_8707_resource_indicators_and_audience_validation
Open
Show file tree
Hide file tree
Changes fromall 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
36 changes: 36 additions & 0 deletionsCLAUDE.md
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,10 @@ Read [cursor rules](.cursorrules).
- Format: `{number}_{description}.{up|down}.sql`
- Number must be unique and sequential
- Always include both up and down migrations
- **Use helper scripts**:
- `./coderd/database/migrations/create_migration.sh "migration name"` - Creates new migration files
- `./coderd/database/migrations/fix_migration_numbers.sh` - Renumbers migrations to avoid conflicts
- `./coderd/database/migrations/create_fixture.sh "fixture name"` - Creates test fixtures for migrations

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

### In-Memory Database Testing

When adding new database fields:

- **CRITICAL**: Update `coderd/database/dbmem/dbmem.go` in-memory implementations
- The `Insert*` functions must include ALL new fields, not just basic ones
- Common issue: Tests pass with real database but fail with in-memory database due to missing field mappings
- Always verify in-memory database functions match the real database schema after migrations

Example pattern:

```go
// In dbmem.go - ensure ALL fields are included
code := database.OAuth2ProviderAppCode{
ID: arg.ID,
CreatedAt: arg.CreatedAt,
// ... existing fields ...
ResourceUri: arg.ResourceUri, // New field
CodeChallenge: arg.CodeChallenge, // New field
CodeChallengeMethod: arg.CodeChallengeMethod, // New field
}
```

## Architecture

### Core Components
Expand DownExpand Up@@ -204,6 +231,12 @@ When working on OAuth2 provider features:
- Avoid dependency on referer headers for security decisions
- Support proper state parameter validation

6. **RFC 8707 Resource Indicators**:
- Store resource parameters in database for server-side validation (opaque tokens)
- Validate resource consistency between authorization and token requests
- Support audience validation in refresh token flows
- Resource parameter is optional but must be consistent when provided

### OAuth2 Error Handling Pattern

```go
Expand DownExpand Up@@ -258,3 +291,6 @@ Always run the full test suite after OAuth2 changes:
2. **"package should be X_test"** - Use `package_test` naming for test files
3. **SQL type errors** - Use `sql.Null*` types for nullable fields
4. **Missing newlines** - Ensure files end with newline character
5. **OAuth2 tests failing but scripts working** - Check in-memory database implementations in `dbmem.go`
6. **Resource indicator validation failing** - Ensure database stores and retrieves resource parameters correctly
7. **PKCE tests failing** - Verify both authorization code storage and token exchange handle PKCE fields
19 changes: 19 additions & 0 deletionscoderd/database/dbauthz/dbauthz.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -2165,6 +2165,25 @@ func (q *querier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID
return q.db.GetOAuth2ProviderAppSecretsByAppID(ctx, appID)
}

func (q *querier) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) {
token, err := q.db.GetOAuth2ProviderAppTokenByAPIKeyID(ctx, apiKeyID)
if err != nil {
return database.OAuth2ProviderAppToken{}, err
}

// Get the associated API key to check ownership
apiKey, err := q.db.GetAPIKeyByID(ctx, token.APIKeyID)
if err != nil {
return database.OAuth2ProviderAppToken{}, err
}

if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppCodeToken.WithOwner(apiKey.UserID.String())); err != nil {
return database.OAuth2ProviderAppToken{}, err
}

return token, nil
}

func (q *querier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (database.OAuth2ProviderAppToken, error) {
token, err := q.db.GetOAuth2ProviderAppTokenByPrefix(ctx, hashPrefix)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletionscoderd/database/dbauthz/setup_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,8 @@ var skipMethods = map[string]string{
"Wrappers": "Not relevant",
"AcquireLock": "Not relevant",
"TryAcquireLock": "Not relevant",
// New OAuth2 resource-indicator methods (RFC 8707); tests to be added
"GetOAuth2ProviderAppTokenByAPIKeyID": "Not relevant",
}

// TestMethodTestSuite runs MethodTestSuite.
Expand Down
30 changes: 23 additions & 7 deletionscoderd/database/dbmem/dbmem.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -4050,6 +4050,19 @@ func (q *FakeQuerier) GetOAuth2ProviderAppSecretsByAppID(_ context.Context, appI
return []database.OAuth2ProviderAppSecret{}, sql.ErrNoRows
}

func (q *FakeQuerier) GetOAuth2ProviderAppTokenByAPIKeyID(_ context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) {
q.mutex.Lock()
defer q.mutex.Unlock()

for _, token := range q.oauth2ProviderAppTokens {
if token.APIKeyID == apiKeyID {
return token, nil
}
}

return database.OAuth2ProviderAppToken{}, sql.ErrNoRows
}

func (q *FakeQuerier) GetOAuth2ProviderAppTokenByPrefix(_ context.Context, hashPrefix []byte) (database.OAuth2ProviderAppToken, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
Expand DownExpand Up@@ -8938,13 +8951,16 @@ func (q *FakeQuerier) InsertOAuth2ProviderAppCode(_ context.Context, arg databas
for _, app := range q.oauth2ProviderApps {
if app.ID == arg.AppID {
code := database.OAuth2ProviderAppCode{
ID: arg.ID,
CreatedAt: arg.CreatedAt,
ExpiresAt: arg.ExpiresAt,
SecretPrefix: arg.SecretPrefix,
HashedSecret: arg.HashedSecret,
UserID: arg.UserID,
AppID: arg.AppID,
ID: arg.ID,
CreatedAt: arg.CreatedAt,
ExpiresAt: arg.ExpiresAt,
SecretPrefix: arg.SecretPrefix,
HashedSecret: arg.HashedSecret,
UserID: arg.UserID,
AppID: arg.AppID,
ResourceUri: arg.ResourceUri,
CodeChallenge: arg.CodeChallenge,
CodeChallengeMethod: arg.CodeChallengeMethod,
}
q.oauth2ProviderAppCodes = append(q.oauth2ProviderAppCodes, code)
return code, nil
Expand Down
7 changes: 7 additions & 0 deletionscoderd/database/dbmetrics/querymetrics.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

15 changes: 15 additions & 0 deletionscoderd/database/dbmock/dbmock.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

1 change: 1 addition & 0 deletionscoderd/database/querier.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

20 changes: 20 additions & 0 deletionscoderd/database/queries.sql.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

3 changes: 3 additions & 0 deletionscoderd/database/queries/oauth2.sql
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,6 +136,9 @@ INSERT INTO oauth2_provider_app_tokens (
-- name: GetOAuth2ProviderAppTokenByPrefix :one
SELECT * FROM oauth2_provider_app_tokens WHERE hash_prefix = $1;

-- name: GetOAuth2ProviderAppTokenByAPIKeyID :one
SELECT * FROM oauth2_provider_app_tokens WHERE api_key_id = $1;

-- name: GetOAuth2ProviderAppsByUserID :many
SELECT
COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count,
Expand Down
51 changes: 51 additions & 0 deletionscoderd/httpmw/apikey.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,16 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}

// Validate OAuth2 provider app token audience (RFC 8707) if applicable
if key.LoginType == database.LoginTypeOAuth2ProviderApp {
if err := validateOAuth2ProviderAppTokenAudience(ctx, cfg.DB, *key, r); err != nil {
return optionalWrite(http.StatusForbidden, codersdk.Response{
Message: "Token audience mismatch",
Detail: err.Error(),
})
}
}

// We only check OIDC stuff if we have a valid APIKey. An expired key means we don't trust the requestor
// really is the user whose key they have, and so we shouldn't be doing anything on their behalf including possibly
// refreshing the OIDC token.
Expand DownExpand Up@@ -446,6 +456,47 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
return key, &actor, true
}

// validateOAuth2ProviderAppTokenAudience validates that an OAuth2 provider app token
// is being used with the correct audience/resource server (RFC 8707).
func validateOAuth2ProviderAppTokenAudience(ctx context.Context, db database.Store, key database.APIKey, r *http.Request) error {
// Get the OAuth2 provider app token to check its audience
//nolint:gocritic // System needs to access token for audience validation
token, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemRestricted(ctx), key.ID)
if err != nil {
return xerrors.Errorf("failed to get OAuth2 token: %w", err)
}

// If no audience is set, allow the request (for backward compatibility)
if !token.Audience.Valid || token.Audience.String == "" {
return nil
}

// Extract the expected audience from the request
expectedAudience := extractExpectedAudience(r)

// Validate that the token's audience matches the expected audience
if token.Audience.String != expectedAudience {
return xerrors.Errorf("token audience %q does not match expected audience %q",
token.Audience.String, expectedAudience)
}

return nil
}

// extractExpectedAudience determines the expected audience for the current request.
// This should match the resource parameter used during authorization.
func extractExpectedAudience(r *http.Request) string {
// For MCP compliance, the audience should be the canonical URI of the resource server
// This typically matches the access URL of the Coder deployment
scheme := "https"
if r.TLS == nil {
scheme = "http"
}

// Use the Host header to construct the canonical audience URI
return fmt.Sprintf("%s://%s", scheme, r.Host)
}

// UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both
// site and organization scopes. It also pulls the groups, and the user's status.
func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scope rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) {
Expand Down
9 changes: 9 additions & 0 deletionscoderd/identityprovider/authorize.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,15 @@ func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizePar
codeChallenge: p.String(vals, "", "code_challenge"),
codeChallengeMethod: p.String(vals, "", "code_challenge_method"),
}
// Validate resource indicator syntax (RFC 8707): must be absolute URI without fragment
if params.resource != "" {
if u, err := url.Parse(params.resource); err != nil || u.Scheme == "" || u.Fragment != "" {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "resource",
Detail: "must be an absolute URI without fragment",
})
}
}

p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
Expand Down
37 changes: 37 additions & 0 deletionscoderd/identityprovider/tokens.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,8 @@ var (
errBadToken = xerrors.New("Invalid token")
// errInvalidPKCE means the PKCE verification failed.
errInvalidPKCE = xerrors.New("invalid code_verifier")
// errInvalidResource means the resource parameter validation failed.
errInvalidResource = xerrors.New("invalid resource parameter")
)

// OAuth2Error represents an OAuth2-compliant error response.
Expand DownExpand Up@@ -87,6 +89,15 @@ func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []c
codeVerifier: p.String(vals, "", "code_verifier"),
resource: p.String(vals, "", "resource"),
}
// Validate resource parameter syntax (RFC 8707): must be absolute URI without fragment
if params.resource != "" {
if u, err := url.Parse(params.resource); err != nil || u.Scheme == "" || u.Fragment != "" {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "resource",
Detail: "must be an absolute URI without fragment",
})
}
}

p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
Expand DownExpand Up@@ -153,6 +164,10 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
writeOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The PKCE code verifier is invalid")
return
}
if errors.Is(err, errInvalidResource) {
writeOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_target", "The resource parameter is invalid")
return
}
if errors.Is(err, errBadToken) {
writeOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The refresh token is invalid or expired")
return
Expand DownExpand Up@@ -229,6 +244,20 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
}
}

// Verify resource parameter consistency (RFC 8707)
if dbCode.ResourceUri.Valid && dbCode.ResourceUri.String != "" {
// Resource was specified during authorization - it must match in token request
if params.resource == "" {
return oauth2.Token{}, errInvalidResource
}
if params.resource != dbCode.ResourceUri.String {
return oauth2.Token{}, errInvalidResource
}
} else if params.resource != "" {
// Resource was not specified during authorization but is now provided
return oauth2.Token{}, errInvalidResource
}

// Generate a refresh token.
refreshToken, err := GenerateSecret()
if err != nil {
Expand DownExpand Up@@ -335,6 +364,14 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
return oauth2.Token{}, errBadToken
}

// Verify resource parameter consistency for refresh tokens (RFC 8707)
if params.resource != "" {
// If resource is provided in refresh request, it must match the original token's audience
if !dbToken.Audience.Valid || dbToken.Audience.String != params.resource {
return oauth2.Token{}, errInvalidResource
}
}

// Grab the user roles so we can perform the refresh as the user.
//nolint:gocritic // There is no user yet so we must use the system.
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
Expand Down
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp