Files
coder/coderd/oauth2_error_compliance_test.go
T
Cian Johnston 46edaf2112 test: reduce number of coderdtest instances (#23463)
Consolidates coderdtest invocations in 7 tests to reduce 23 instances to 7 across:
- `TestGetUser` (3 → 1) — read-only user lookups
- `TestUserTerminalFont` (3 → 1) — each creates own user via
CreateAnotherUser
- `TestUserTaskNotificationAlertDismissed` (3 → 1) — each creates own
user
- `TestUserLogin` (3 → 1) — each creates/deletes own user
- `TestExpMcpConfigureClaudeCode` (5 → 1) — writes to isolated temp dirs
- `TestOAuth2RegistrationTokenSecurity` (3 → 1) — independent
registrations
- `TestOAuth2SpecificErrorScenarios` (3 → 1) — independent error
scenarios

> 🤖 This PR was created with the help of Coder Agents, and has been
reviewed by my human. 🧑‍💻
2026-03-25 09:53:06 +00:00

432 lines
14 KiB
Go

package coderd_test
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// OAuth2ErrorResponse represents RFC-compliant OAuth2 error responses
type OAuth2ErrorResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
ErrorURI string `json:"error_uri,omitempty"`
}
// TestOAuth2ErrorResponseFormat tests that OAuth2 error responses follow proper RFC format
func TestOAuth2ErrorResponseFormat(t *testing.T) {
t.Parallel()
t.Run("ContentTypeHeader", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Make a request that will definitely fail
req := codersdk.OAuth2ClientRegistrationRequest{
// Missing required redirect_uris
}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
require.Error(t, err)
// Check that it's an HTTP error with JSON content type
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
// The error should be a 400 status for invalid client metadata
require.Equal(t, http.StatusBadRequest, httpErr.StatusCode())
})
}
// TestOAuth2RegistrationErrorCodes tests all RFC 7591 error codes
func TestOAuth2RegistrationErrorCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req codersdk.OAuth2ClientRegistrationRequest
expectedError string
expectedCode int
}{
{
name: "InvalidClientMetadata_NoRedirectURIs",
req: codersdk.OAuth2ClientRegistrationRequest{
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
// Missing required redirect_uris
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_InvalidRedirectURI",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"not-a-valid-uri"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_RedirectURIWithFragment",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback#fragment"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_HTTPRedirectForNonLocalhost",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"http://example.com/callback"}, // HTTP for non-localhost
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_UnsupportedGrantType",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
GrantTypes: []codersdk.OAuth2ProviderGrantType{"unsupported_grant_type"},
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_UnsupportedResponseType",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
ResponseTypes: []codersdk.OAuth2ProviderResponseType{"unsupported_response_type"},
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_UnsupportedAuthMethod",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "unsupported_auth_method",
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_InvalidClientURI",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
ClientURI: "not-a-valid-uri",
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
{
name: "InvalidClientMetadata_InvalidLogoURI",
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
LogoURI: "not-a-valid-uri",
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Create a copy of the request with a unique client name
req := test.req
if req.ClientName != "" {
req.ClientName = fmt.Sprintf("%s-%d", req.ClientName, time.Now().UnixNano())
}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
require.Error(t, err)
// Validate error format and status code
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
require.Equal(t, test.expectedCode, httpErr.StatusCode())
// For now, just verify we get an error with the expected status code
// The specific error message format can be verified in other ways
require.True(t, httpErr.StatusCode() >= 400)
})
}
}
// TestOAuth2ManagementErrorCodes tests all RFC 7592 error codes
func TestOAuth2ManagementErrorCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
useWrongClientID bool
useWrongToken bool
useEmptyToken bool
expectedError string
expectedCode int
}{
{
name: "InvalidToken_WrongToken",
useWrongToken: true,
expectedError: "invalid_token",
expectedCode: http.StatusUnauthorized,
},
{
name: "InvalidToken_EmptyToken",
useEmptyToken: true,
expectedError: "invalid_token",
expectedCode: http.StatusUnauthorized,
},
{
name: "InvalidClient_WrongClientID",
useWrongClientID: true,
expectedError: "invalid_token",
expectedCode: http.StatusUnauthorized,
},
// Skip empty client ID test as it causes routing issues
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// First register a valid client to use for management tests
clientName := fmt.Sprintf("test-client-%d", time.Now().UnixNano())
regReq := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: clientName,
}
regResp, err := client.PostOAuth2ClientRegistration(ctx, regReq)
require.NoError(t, err)
// Determine clientID and token based on test configuration
var clientID, token string
switch {
case test.useWrongClientID:
clientID = "550e8400-e29b-41d4-a716-446655440000" // Valid UUID format but non-existent
token = regResp.RegistrationAccessToken
case test.useWrongToken:
clientID = regResp.ClientID
token = "invalid-token"
case test.useEmptyToken:
clientID = regResp.ClientID
token = ""
default:
clientID = regResp.ClientID
token = regResp.RegistrationAccessToken
}
// Test GET client configuration
_, err = client.GetOAuth2ClientConfiguration(ctx, clientID, token)
require.Error(t, err)
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
require.Equal(t, test.expectedCode, httpErr.StatusCode())
// Verify we get an appropriate error status code
require.True(t, httpErr.StatusCode() >= 400)
// Test PUT client configuration (except for empty client ID which causes routing issues)
if clientID != "" {
updateReq := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://updated.example.com/callback"},
ClientName: clientName + "-updated",
}
_, err = client.PutOAuth2ClientConfiguration(ctx, clientID, token, updateReq)
require.Error(t, err)
require.ErrorAs(t, err, &httpErr)
require.Equal(t, test.expectedCode, httpErr.StatusCode())
require.True(t, httpErr.StatusCode() >= 400)
// Test DELETE client configuration
err = client.DeleteOAuth2ClientConfiguration(ctx, clientID, token)
require.Error(t, err)
require.ErrorAs(t, err, &httpErr)
require.Equal(t, test.expectedCode, httpErr.StatusCode())
require.True(t, httpErr.StatusCode() >= 400)
}
})
}
}
// TestOAuth2ErrorResponseStructure tests the JSON structure of error responses
func TestOAuth2ErrorResponseStructure(t *testing.T) {
t.Parallel()
t.Run("ErrorFieldsPresent", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Make a request that will generate an error
req := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"invalid-uri"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
require.Error(t, err)
// Validate that the error contains the expected OAuth2 error structure
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
// The error should be a 400 status for invalid client metadata
require.Equal(t, http.StatusBadRequest, httpErr.StatusCode())
// Should have error details
require.NotEmpty(t, httpErr.Message)
})
t.Run("RegistrationAccessTokenErrors", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Try to access a client configuration with invalid token - use a valid UUID format
validUUID := "550e8400-e29b-41d4-a716-446655440000"
_, err := client.GetOAuth2ClientConfiguration(ctx, validUUID, "invalid-token")
require.Error(t, err)
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
require.Equal(t, http.StatusUnauthorized, httpErr.StatusCode())
})
}
// TestOAuth2ErrorHTTPHeaders tests that error responses have correct HTTP headers
func TestOAuth2ErrorHTTPHeaders(t *testing.T) {
t.Parallel()
t.Run("ContentTypeJSON", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Make a request that will fail
req := codersdk.OAuth2ClientRegistrationRequest{
// Missing required fields
}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
require.Error(t, err)
// The error should indicate proper JSON response format
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
require.NotEmpty(t, httpErr.Message)
})
}
// TestOAuth2SpecificErrorScenarios tests specific error scenarios from RFC specifications
func TestOAuth2SpecificErrorScenarios(t *testing.T) {
t.Parallel()
// Single instance shared across all sub-tests that need a
// coderd server. Sub-tests that don't need one just ignore it.
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
t.Run("MissingRequiredFields", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Test completely empty request
req := codersdk.OAuth2ClientRegistrationRequest{}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
require.Error(t, err)
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
require.Equal(t, http.StatusBadRequest, httpErr.StatusCode())
// Error properly returned with bad request status
})
t.Run("InvalidJSONStructure", func(t *testing.T) {
t.Parallel()
// For invalid JSON structure, we'd need to make raw HTTP requests
// This is tested implicitly through the other tests since we're using
// typed requests that ensure proper JSON structure
})
t.Run("UnsupportedFields", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Test with fields that might not be supported yet
req := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "private_key_jwt", // Not supported yet
}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
require.Error(t, err)
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
require.Equal(t, http.StatusBadRequest, httpErr.StatusCode())
// Error properly returned with bad request status
})
t.Run("SecurityBoundaryErrors", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Register a client first
clientName := fmt.Sprintf("test-client-%d", time.Now().UnixNano())
regReq := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: clientName,
}
regResp, err := client.PostOAuth2ClientRegistration(ctx, regReq)
require.NoError(t, err)
// Try to access with completely wrong token format
_, err = client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, "malformed-token-format")
require.Error(t, err)
var httpErr *codersdk.Error
require.ErrorAs(t, err, &httpErr)
require.Equal(t, http.StatusUnauthorized, httpErr.StatusCode())
})
}