- 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.
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_tokenis 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_invalue from provider - Review token expiry buffer (default 60s)
Next Steps
With OAuth foundation in place, you can now:
- Implement Pre-Save Campaigns - Use stored tokens for automated library additions
- Add More Providers - Tidal, Apple Music, Google, etc.
- User Dashboard - Show connected providers, manage connections
- Analytics - Track OAuth usage, conversion rates
- Email Integration - Notify users on successful pre-save