mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
refactor: move OAuth2 provider code to dedicated package (#18746)
# Refactor OAuth2 Provider Code into Dedicated Package This PR refactors the OAuth2 provider functionality by moving it from the main `coderd` package into a dedicated `oauth2provider` package. The change improves code organization and maintainability without changing functionality. Key changes: - Created a new `oauth2provider` package to house all OAuth2 provider-related code - Moved existing OAuth2 provider functionality from `coderd/identityprovider` to the new package - Refactored handler functions to follow a consistent pattern of returning `http.HandlerFunc` instead of being handlers directly - Split large files into smaller, more focused files organized by functionality: - `app_secrets.go` - Manages OAuth2 application secrets - `apps.go` - Handles OAuth2 application CRUD operations - `authorize.go` - Implements the authorization flow - `metadata.go` - Provides OAuth2 metadata endpoints - `registration.go` - Handles dynamic client registration - `revoke.go` - Implements token revocation - `secrets.go` - Manages secret generation and validation - `tokens.go` - Handles token issuance and validation This refactoring improves code organization and makes the OAuth2 provider functionality more maintainable while preserving all existing behavior.
This commit is contained in:
328
coderd/oauth2provider/oauth2providertest/helpers.go
Normal file
328
coderd/oauth2provider/oauth2providertest/helpers.go
Normal file
@ -0,0 +1,328 @@
|
||||
// Package oauth2providertest provides comprehensive testing utilities for OAuth2 identity provider functionality.
|
||||
// It includes helpers for creating OAuth2 apps, performing authorization flows, token exchanges,
|
||||
// PKCE challenge generation and verification, and testing error scenarios.
|
||||
package oauth2providertest
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// AuthorizeParams contains parameters for OAuth2 authorization
|
||||
type AuthorizeParams struct {
|
||||
ClientID string
|
||||
ResponseType string
|
||||
RedirectURI string
|
||||
State string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
Resource string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// TokenExchangeParams contains parameters for token exchange
|
||||
type TokenExchangeParams struct {
|
||||
GrantType string
|
||||
Code string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
CodeVerifier string
|
||||
RedirectURI string
|
||||
RefreshToken string
|
||||
Resource string
|
||||
}
|
||||
|
||||
// OAuth2Error represents an OAuth2 error response
|
||||
type OAuth2Error struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTestOAuth2App creates an OAuth2 app for testing and returns the app and client secret
|
||||
func CreateTestOAuth2App(t *testing.T, client *codersdk.Client) (*codersdk.OAuth2ProviderApp, string) {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create unique app name with random suffix
|
||||
appName := fmt.Sprintf("test-oauth2-app-%s", testutil.MustRandString(t, 10))
|
||||
|
||||
req := codersdk.PostOAuth2ProviderAppRequest{
|
||||
Name: appName,
|
||||
CallbackURL: TestRedirectURI,
|
||||
}
|
||||
|
||||
app, err := client.PostOAuth2ProviderApp(ctx, req)
|
||||
require.NoError(t, err, "failed to create OAuth2 app")
|
||||
|
||||
// Create client secret
|
||||
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
|
||||
require.NoError(t, err, "failed to create OAuth2 app secret")
|
||||
|
||||
return &app, secret.ClientSecretFull
|
||||
}
|
||||
|
||||
// GeneratePKCE generates a random PKCE code verifier and challenge
|
||||
func GeneratePKCE(t *testing.T) (verifier, challenge string) {
|
||||
t.Helper()
|
||||
|
||||
// Generate 32 random bytes for verifier
|
||||
bytes := make([]byte, 32)
|
||||
_, err := rand.Read(bytes)
|
||||
require.NoError(t, err, "failed to generate random bytes")
|
||||
|
||||
// Create code verifier (base64url encoding without padding)
|
||||
verifier = base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Create code challenge using S256 method
|
||||
challenge = GenerateCodeChallenge(verifier)
|
||||
|
||||
return verifier, challenge
|
||||
}
|
||||
|
||||
// GenerateState generates a random state parameter
|
||||
func GenerateState(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
bytes := make([]byte, 16)
|
||||
_, err := rand.Read(bytes)
|
||||
require.NoError(t, err, "failed to generate random bytes")
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// AuthorizeOAuth2App performs the OAuth2 authorization flow and returns the authorization code
|
||||
func AuthorizeOAuth2App(t *testing.T, client *codersdk.Client, baseURL string, params AuthorizeParams) string {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Build authorization URL
|
||||
authURL, err := url.Parse(baseURL + "/oauth2/authorize")
|
||||
require.NoError(t, err, "failed to parse authorization URL")
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("client_id", params.ClientID)
|
||||
query.Set("response_type", params.ResponseType)
|
||||
query.Set("redirect_uri", params.RedirectURI)
|
||||
query.Set("state", params.State)
|
||||
|
||||
if params.CodeChallenge != "" {
|
||||
query.Set("code_challenge", params.CodeChallenge)
|
||||
query.Set("code_challenge_method", params.CodeChallengeMethod)
|
||||
}
|
||||
if params.Resource != "" {
|
||||
query.Set("resource", params.Resource)
|
||||
}
|
||||
if params.Scope != "" {
|
||||
query.Set("scope", params.Scope)
|
||||
}
|
||||
|
||||
authURL.RawQuery = query.Encode()
|
||||
|
||||
// Create POST request to authorize endpoint (simulating user clicking "Allow")
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", authURL.String(), nil)
|
||||
require.NoError(t, err, "failed to create authorization request")
|
||||
|
||||
// Add session token
|
||||
req.Header.Set("Coder-Session-Token", client.SessionToken())
|
||||
|
||||
// Perform request
|
||||
httpClient := &http.Client{
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
// Don't follow redirects, we want to capture the redirect URL
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err, "failed to perform authorization request")
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should get a redirect response (either 302 Found or 307 Temporary Redirect)
|
||||
require.True(t, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusTemporaryRedirect,
|
||||
"expected redirect response, got %d", resp.StatusCode)
|
||||
|
||||
// Extract redirect URL
|
||||
location := resp.Header.Get("Location")
|
||||
require.NotEmpty(t, location, "missing Location header in redirect response")
|
||||
|
||||
// Parse redirect URL to extract authorization code
|
||||
redirectURL, err := url.Parse(location)
|
||||
require.NoError(t, err, "failed to parse redirect URL")
|
||||
|
||||
code := redirectURL.Query().Get("code")
|
||||
require.NotEmpty(t, code, "missing authorization code in redirect URL")
|
||||
|
||||
// Verify state parameter
|
||||
returnedState := redirectURL.Query().Get("state")
|
||||
require.Equal(t, params.State, returnedState, "state parameter mismatch")
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// ExchangeCodeForToken exchanges an authorization code for tokens
|
||||
func ExchangeCodeForToken(t *testing.T, baseURL string, params TokenExchangeParams) *oauth2.Token {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Prepare form data
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", params.GrantType)
|
||||
|
||||
if params.Code != "" {
|
||||
data.Set("code", params.Code)
|
||||
}
|
||||
if params.ClientID != "" {
|
||||
data.Set("client_id", params.ClientID)
|
||||
}
|
||||
if params.ClientSecret != "" {
|
||||
data.Set("client_secret", params.ClientSecret)
|
||||
}
|
||||
if params.CodeVerifier != "" {
|
||||
data.Set("code_verifier", params.CodeVerifier)
|
||||
}
|
||||
if params.RedirectURI != "" {
|
||||
data.Set("redirect_uri", params.RedirectURI)
|
||||
}
|
||||
if params.RefreshToken != "" {
|
||||
data.Set("refresh_token", params.RefreshToken)
|
||||
}
|
||||
if params.Resource != "" {
|
||||
data.Set("resource", params.Resource)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
|
||||
require.NoError(t, err, "failed to create token request")
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Perform request
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "failed to perform token request")
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse response
|
||||
var tokenResp oauth2.Token
|
||||
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
|
||||
require.NoError(t, err, "failed to decode token response")
|
||||
|
||||
require.NotEmpty(t, tokenResp.AccessToken, "missing access token")
|
||||
require.Equal(t, "Bearer", tokenResp.TokenType, "unexpected token type")
|
||||
|
||||
return &tokenResp
|
||||
}
|
||||
|
||||
// RequireOAuth2Error checks that the HTTP response contains an expected OAuth2 error
|
||||
func RequireOAuth2Error(t *testing.T, resp *http.Response, expectedError string) {
|
||||
t.Helper()
|
||||
|
||||
var errorResp OAuth2Error
|
||||
err := json.NewDecoder(resp.Body).Decode(&errorResp)
|
||||
require.NoError(t, err, "failed to decode error response")
|
||||
|
||||
require.Equal(t, expectedError, errorResp.Error, "unexpected OAuth2 error code")
|
||||
require.NotEmpty(t, errorResp.ErrorDescription, "missing error description")
|
||||
}
|
||||
|
||||
// PerformTokenExchangeExpectingError performs a token exchange expecting an OAuth2 error
|
||||
func PerformTokenExchangeExpectingError(t *testing.T, baseURL string, params TokenExchangeParams, expectedError string) {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Prepare form data
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", params.GrantType)
|
||||
|
||||
if params.Code != "" {
|
||||
data.Set("code", params.Code)
|
||||
}
|
||||
if params.ClientID != "" {
|
||||
data.Set("client_id", params.ClientID)
|
||||
}
|
||||
if params.ClientSecret != "" {
|
||||
data.Set("client_secret", params.ClientSecret)
|
||||
}
|
||||
if params.CodeVerifier != "" {
|
||||
data.Set("code_verifier", params.CodeVerifier)
|
||||
}
|
||||
if params.RedirectURI != "" {
|
||||
data.Set("redirect_uri", params.RedirectURI)
|
||||
}
|
||||
if params.RefreshToken != "" {
|
||||
data.Set("refresh_token", params.RefreshToken)
|
||||
}
|
||||
if params.Resource != "" {
|
||||
data.Set("resource", params.Resource)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
|
||||
require.NoError(t, err, "failed to create token request")
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Perform request
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "failed to perform token request")
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should be a 4xx error
|
||||
require.True(t, resp.StatusCode >= 400 && resp.StatusCode < 500, "expected 4xx status code, got %d", resp.StatusCode)
|
||||
|
||||
// Check OAuth2 error
|
||||
RequireOAuth2Error(t, resp, expectedError)
|
||||
}
|
||||
|
||||
// FetchOAuth2Metadata fetches and returns OAuth2 authorization server metadata
|
||||
func FetchOAuth2Metadata(t *testing.T, baseURL string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/.well-known/oauth-authorization-server", nil)
|
||||
require.NoError(t, err, "failed to create metadata request")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "failed to fetch metadata")
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "unexpected metadata response status")
|
||||
|
||||
var metadata map[string]any
|
||||
err = json.NewDecoder(resp.Body).Decode(&metadata)
|
||||
require.NoError(t, err, "failed to decode metadata response")
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// CleanupOAuth2App deletes an OAuth2 app (helper for test cleanup)
|
||||
func CleanupOAuth2App(t *testing.T, client *codersdk.Client, appID uuid.UUID) {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := client.DeleteOAuth2ProviderApp(ctx, appID)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to cleanup OAuth2 app %s: %v", appID, err)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user