Files
michaelschiemer/src/Framework/OAuth
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00
..

OAuth System

Generic OAuth 2.0 authentication system with maximum type safety through Value Objects.

Overview

The OAuth System provides a robust, framework-compliant implementation for third-party authentication with:

  • Full Value Object Architecture - AccessToken, RefreshToken, TokenType, TokenScope
  • Provider Interface - Easy integration of Spotify, Tidal, Google, etc.
  • Automatic Token Refresh - Transparent refresh handling
  • Secure Storage - Database persistence with masked logging
  • Background Jobs - Automated token refresh and cleanup

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        OAuth Flow                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│  1. User → /oauth/{provider}/authorize                           │
│  2. Redirect → Provider Authorization Page                       │
│  3. User Authorizes                                              │
│  4. Provider → /oauth/{provider}/callback?code=xxx               │
│  5. Exchange Code → Access + Refresh Token                       │
│  6. Store Token → Database                                       │
│  7. Auto-Refresh when expired                                    │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────┐    ┌─────────────────┐    ┌──────────────────┐
│  OAuthService   │───▶│  OAuthProvider  │───▶│  OAuthClient     │
│  (Orchestration)│    │  (Spotify, etc) │    │  (HTTP Requests) │
└────────┬────────┘    └─────────────────┘    └──────────────────┘
         │
         ▼
┌─────────────────────┐    ┌──────────────────────────────────────┐
│ OAuthTokenRepository│───▶│  Value Objects                        │
│ (Persistence)       │    │  - AccessToken (with expiry)          │
└─────────────────────┘    │  - RefreshToken                       │
                           │  - TokenType (Bearer/MAC)             │
                           │  - TokenScope (permissions)           │
                           │  - OAuthToken (composite)             │
                           └──────────────────────────────────────┘

Core Components

1. Value Objects

AccessToken - Access token with expiration and masking:

$accessToken = AccessToken::fromProviderResponse(
    'ya29.a0AfH6SMBx...',
    3600  // expires in seconds
);

$accessToken->isExpired();  // false
$accessToken->getMasked();  // "ya29****6SMBx"
$accessToken->toString();   // Full token

RefreshToken - Long-lived token for renewal:

$refreshToken = RefreshToken::create('1//0gHd...');
$refreshToken->getMasked();  // "1//0****gHd."

TokenType - RFC 6749 compliant token types:

$type = TokenType::BEARER;  // or TokenType::MAC
$type = TokenType::fromString('Bearer');
$type->getHeaderPrefix();  // "Bearer"

TokenScope - OAuth permissions:

$scope = TokenScope::fromString('user-read-email user-library-modify');
$scope->includes('user-read-email');  // true
$scope->includesAll(['user-read-email', 'user-library-modify']);  // true
$scope->toString();  // "user-read-email user-library-modify"

OAuthToken - Composite token:

$token = OAuthToken::fromProviderResponse([
    'access_token' => 'ya29...',
    'refresh_token' => '1//0g...',
    'expires_in' => 3600,
    'token_type' => 'Bearer',
    'scope' => 'user-read-email user-library-modify'
]);

$token->isExpired();
$token->canRefresh();
$token->getAuthorizationHeader();  // "Bearer ya29..."
$token->hasScope('user-read-email');

2. Provider Interface

Implement OAuthProvider for each service:

interface OAuthProvider
{
    public function getName(): string;
    public function getAuthorizationUrl(array $options = []): string;
    public function getAccessToken(string $code, ?string $state = null): OAuthToken;
    public function refreshToken(OAuthToken $token): OAuthToken;
    public function revokeToken(OAuthToken $token): bool;
    public function getUserProfile(OAuthToken $token): array;
    public function getDefaultScopes(): array;
}

3. Spotify Provider

Pre-configured Spotify implementation:

$spotifyProvider = new SpotifyProvider(
    client: $oauthClient,
    clientId: $_ENV['SPOTIFY_CLIENT_ID'],
    clientSecret: $_ENV['SPOTIFY_CLIENT_SECRET'],
    redirectUri: 'https://yourapp.com/oauth/spotify/callback'
);

// Authorization URL
$authUrl = $spotifyProvider->getAuthorizationUrl([
    'scope' => ['user-library-read', 'user-library-modify'],
    'state' => 'random-state-string'
]);

// Exchange code for token
$token = $spotifyProvider->getAccessToken($code);

// Refresh expired token
$newToken = $spotifyProvider->refreshToken($token);

// Get user profile
$profile = $spotifyProvider->getUserProfile($token);

// Pre-Save Campaign Methods
$spotifyProvider->addTracksToLibrary($token, ['track_id_1', 'track_id_2']);
$inLibrary = $spotifyProvider->checkTracksInLibrary($token, ['track_id_1']);

4. Token Storage

StoredOAuthToken - Persistence entity:

$stored = StoredOAuthToken::create(
    userId: 'user_123',
    provider: 'spotify',
    token: $oauthToken
);

$stored->isExpired();
$stored->canRefresh();
$stored->toArray();  // For database storage

OAuthTokenRepository - Database operations:

$repository = new OAuthTokenRepository($connection);

// Save/update token
$stored = $repository->saveForUser('user_123', 'spotify', $token);

// Retrieve token
$stored = $repository->findForUser('user_123', 'spotify');

// Delete token
$repository->deleteForUser('user_123', 'spotify');

// Background jobs
$expiring = $repository->findExpiringSoon(300);  // Within 5 minutes
$deletedCount = $repository->deleteExpired();    // Cleanup

5. OAuth Service

Central orchestration service with auto-refresh:

$oauthService = new OAuthService(
    tokenRepository: $repository,
    providers: [
        'spotify' => $spotifyProvider,
        'tidal' => $tidalProvider,
    ]
);

// Get authorization URL
$authUrl = $oauthService->getAuthorizationUrl('spotify', [
    'scope' => ['user-library-read', 'user-library-modify']
]);

// Handle callback
$storedToken = $oauthService->handleCallback(
    userId: 'user_123',
    provider: 'spotify',
    code: $code,
    state: $state
);

// Get token with auto-refresh
$storedToken = $oauthService->getTokenForUser('user_123', 'spotify');
// Token automatically refreshed if expired!

// Revoke connection
$oauthService->revokeToken('user_123', 'spotify');

// Get user profile
$profile = $oauthService->getUserProfile('user_123', 'spotify');

// Background jobs
$refreshed = $oauthService->refreshExpiringTokens(300);
$deleted = $oauthService->cleanupExpiredTokens();

Usage Examples

Basic OAuth Flow

// 1. Redirect to provider
#[Route('/oauth/{provider}/authorize', Method::GET)]
public function authorize(string $provider): Redirect
{
    $authUrl = $this->oauthService->getAuthorizationUrl($provider);
    return new Redirect($authUrl);
}

// 2. Handle callback
#[Route('/oauth/{provider}/callback', Method::GET)]
public function callback(string $provider, HttpRequest $request): Redirect
{
    $code = $request->query->get('code');
    $userId = $this->getCurrentUserId();

    $storedToken = $this->oauthService->handleCallback(
        $userId,
        $provider,
        $code
    );

    return new Redirect('/dashboard', flashMessage: 'Connected!');
}

// 3. Use token
public function addToLibrary(string $userId, array $trackIds): void
{
    // Auto-refreshes if expired!
    $storedToken = $this->oauthService->getTokenForUser($userId, 'spotify');

    $this->spotifyProvider->addTracksToLibrary(
        $storedToken->token,
        $trackIds
    );
}

Pre-Save Campaign Integration

final readonly class PreSaveCampaignProcessor
{
    public function __construct(
        private OAuthService $oauthService,
        private SpotifyProvider $spotifyProvider,
    ) {}

    public function processRelease(PreSaveCampaign $campaign): void
    {
        $registrations = $campaign->getRegistrations();

        foreach ($registrations as $registration) {
            try {
                // Get token with auto-refresh
                $storedToken = $this->oauthService->getTokenForUser(
                    $registration->userId,
                    'spotify'
                );

                // Add to library
                $this->spotifyProvider->addTracksToLibrary(
                    $storedToken->token,
                    [$campaign->spotifyTrackId]
                );

                $registration->markAsProcessed();
            } catch (\Exception $e) {
                $registration->markAsFailed($e->getMessage());
            }
        }
    }
}

Background Token Refresh Worker

#[Schedule(interval: Duration::fromMinutes(5))]
final readonly class RefreshOAuthTokensJob
{
    public function __construct(
        private OAuthService $oauthService,
    ) {}

    public function handle(): array
    {
        // Refresh tokens expiring within 5 minutes
        $refreshed = $this->oauthService->refreshExpiringTokens(300);

        // Cleanup expired tokens without refresh token
        $deleted = $this->oauthService->cleanupExpiredTokens();

        return [
            'refreshed' => $refreshed,
            'deleted' => $deleted,
        ];
    }
}

Configuration

Environment Variables

# Spotify
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
SPOTIFY_REDIRECT_URI=https://yourapp.com/oauth/spotify/callback

# Tidal
TIDAL_CLIENT_ID=your_client_id
TIDAL_CLIENT_SECRET=your_client_secret
TIDAL_REDIRECT_URI=https://yourapp.com/oauth/tidal/callback

Container Registration

// In an Initializer
final readonly class OAuthInitializer implements Initializer
{
    public function initialize(Container $container): void
    {
        // Register HTTP Client
        $container->singleton(OAuthClient::class, fn() =>
            new OAuthClient($container->get(HttpClient::class))
        );

        // Register Spotify Provider
        $container->singleton(SpotifyProvider::class, fn() =>
            new SpotifyProvider(
                client: $container->get(OAuthClient::class),
                clientId: $_ENV['SPOTIFY_CLIENT_ID'],
                clientSecret: $_ENV['SPOTIFY_CLIENT_SECRET'],
                redirectUri: $_ENV['SPOTIFY_REDIRECT_URI']
            )
        );

        // Register OAuth Service
        $container->singleton(OAuthService::class, fn() =>
            new OAuthService(
                tokenRepository: $container->get(OAuthTokenRepository::class),
                providers: [
                    'spotify' => $container->get(SpotifyProvider::class),
                ]
            )
        );
    }
}

Database Schema

CREATE TABLE oauth_tokens (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id         VARCHAR(255) NOT NULL,
    provider        VARCHAR(50) NOT NULL,
    access_token    TEXT NOT NULL,
    refresh_token   TEXT,
    token_type      VARCHAR(20) NOT NULL DEFAULT 'Bearer',
    scope           TEXT,
    expires_at      INTEGER NOT NULL,
    created_at      INTEGER NOT NULL,
    updated_at      INTEGER NOT NULL,

    UNIQUE(user_id, provider)
);

CREATE INDEX idx_oauth_tokens_user_id ON oauth_tokens(user_id);
CREATE INDEX idx_oauth_tokens_provider ON oauth_tokens(provider);
CREATE INDEX idx_oauth_tokens_expires_at ON oauth_tokens(expires_at);

Run migration:

php console.php db:migrate

Framework Compliance

No Inheritance - All classes final readonly, pure composition Value Objects - AccessToken, RefreshToken, TokenType, TokenScope Type Safety - No primitive obsession, strong typing throughout Immutability - All Value Objects immutable with transformation methods Explicit DI - Constructor injection, no service locators Security - Token masking in logs, secure storage

Adding New Providers

1. Implement OAuthProvider

final readonly class TidalProvider implements OAuthProvider
{
    private const AUTHORIZATION_URL = 'https://login.tidal.com/authorize';
    private const TOKEN_URL = 'https://auth.tidal.com/v1/oauth2/token';

    public function getName(): string
    {
        return 'tidal';
    }

    public function getDefaultScopes(): array
    {
        return ['r_usr', 'w_usr'];
    }

    public function getAuthorizationUrl(array $options = []): string
    {
        // Implementation...
    }

    // Implement other methods...
}

2. Register in Container

$container->singleton(TidalProvider::class, fn() =>
    new TidalProvider(
        client: $container->get(OAuthClient::class),
        clientId: $_ENV['TIDAL_CLIENT_ID'],
        clientSecret: $_ENV['TIDAL_CLIENT_SECRET'],
        redirectUri: $_ENV['TIDAL_REDIRECT_URI']
    )
);

// Add to OAuthService
$container->singleton(OAuthService::class, fn() =>
    new OAuthService(
        tokenRepository: $container->get(OAuthTokenRepository::class),
        providers: [
            'spotify' => $container->get(SpotifyProvider::class),
            'tidal' => $container->get(TidalProvider::class),
        ]
    )
);

Security Considerations

  • State Parameter: Use CSRF state tokens in authorization flow
  • Token Masking: Tokens automatically masked in logs (toArrayMasked())
  • Secure Storage: Tokens stored encrypted at rest (recommended)
  • HTTPS Only: OAuth callbacks must use HTTPS
  • Token Expiry: Automatic refresh handles expiration transparently
  • Scope Validation: Validate required scopes before operations

Testing

// Test token creation
it('creates access token from provider response', function () {
    $token = AccessToken::fromProviderResponse('abc123', 3600);

    expect($token->toString())->toBe('abc123');
    expect($token->isExpired())->toBeFalse();
    expect($token->getMasked())->toBe('abc1****c123');
});

// Test OAuth flow
it('handles OAuth callback and stores token', function () {
    $service = new OAuthService($repository, ['spotify' => $provider]);

    $storedToken = $service->handleCallback('user_123', 'spotify', 'auth_code');

    expect($storedToken->userId)->toBe('user_123');
    expect($storedToken->provider)->toBe('spotify');
    expect($storedToken->token)->toBeInstanceOf(OAuthToken::class);
});

// Test auto-refresh
it('automatically refreshes expired tokens', function () {
    $expiredToken = createExpiredToken();
    $repository->save($expiredToken);

    // Should auto-refresh
    $refreshed = $service->getTokenForUser('user_123', 'spotify');

    expect($refreshed->isExpired())->toBeFalse();
});

Troubleshooting

Token not refreshing:

  • Check refresh_token is not null in database
  • Verify provider's refreshToken() implementation
  • Ensure provider credentials are correct

Authorization fails:

  • Verify redirect URI matches exactly (including trailing slash)
  • Check state parameter validation
  • Ensure scopes are valid for provider

Token expires immediately:

  • Check system time synchronization
  • Verify expires_in value from provider
  • Review token expiry buffer (default 60s)

Next Steps

With OAuth foundation in place, you can now:

  1. Implement Pre-Save Campaigns - Use stored tokens for automated library additions
  2. Add More Providers - Tidal, Apple Music, Google, etc.
  3. User Dashboard - Show connected providers, manage connections
  4. Analytics - Track OAuth usage, conversion rates
  5. Email Integration - Notify users on successful pre-save