# 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:
```php
$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:
```php
$refreshToken = RefreshToken::create('1//0gHd...');
$refreshToken->getMasked();  // "1//0****gHd."
```

**TokenType** - RFC 6749 compliant token types:
```php
$type = TokenType::BEARER;  // or TokenType::MAC
$type = TokenType::fromString('Bearer');
$type->getHeaderPrefix();  // "Bearer"
```

**TokenScope** - OAuth permissions:
```php
$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:
```php
$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:

```php
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:

```php
$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:
```php
$stored = StoredOAuthToken::create(
    userId: 'user_123',
    provider: 'spotify',
    token: $oauthToken
);

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

**OAuthTokenRepository** - Database operations:
```php
$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:

```php
$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

```php
// 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

```php
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

```php
#[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

```env
# 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

```php
// 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

```sql
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:
```bash
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

```php
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

```php
$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

```php
// 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
