docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
/**
* Create OAuth Tokens Table
*
* Stores OAuth access/refresh tokens for third-party provider authentication
*/
final class CreateOAuthTokensTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->createIfNotExists('oauth_tokens', function (Blueprint $table) {
$table->id();
$table->string('user_id', 255);
$table->string('provider', 50);
$table->text('access_token');
$table->text('refresh_token')->nullable();
$table->string('token_type', 20)->default('Bearer');
$table->text('scope')->nullable();
$table->bigInteger('expires_at');
$table->timestamps();
// Unique constraint
$table->unique(['user_id', 'provider']);
// Indexes
$table->index(['user_id']);
$table->index(['provider']);
$table->index(['expires_at']);
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('oauth_tokens');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp("2025_01_10_140000");
}
public function getDescription(): string
{
return "Create OAuth tokens table for third-party authentication";
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth;
use App\Framework\HttpClient\HttpClient;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* Generic OAuth Client
*
* Handles HTTP requests to OAuth providers using framework's HttpClient
*/
final readonly class OAuthClient
{
public function __construct(
private HttpClient $httpClient,
) {}
/**
* Make GET request to OAuth provider
*
* @param array<string, string> $headers
* @return array<string, mixed>
*/
public function get(string $url, OAuthToken $token, array $headers = []): array
{
$response = $this->httpClient->get($url, [
'headers' => [
'Authorization' => $token->getAuthorizationHeader(),
...$headers,
],
]);
return json_decode($response->getBody(), true) ?? [];
}
/**
* Make POST request to OAuth provider
*
* @param array<string, mixed> $data
* @param array<string, string> $headers
* @return array<string, mixed>
*/
public function post(string $url, array $data = [], array $headers = []): array
{
$response = $this->httpClient->post($url, [
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
...$headers,
],
'body' => http_build_query($data),
]);
return json_decode($response->getBody(), true) ?? [];
}
/**
* Make authenticated POST request with OAuth token
*
* @param array<string, mixed> $data
* @param array<string, string> $headers
* @return array<string, mixed>
*/
public function postAuthenticated(
string $url,
OAuthToken $token,
array $data = [],
array $headers = []
): array {
$response = $this->httpClient->post($url, [
'headers' => [
'Authorization' => $token->getAuthorizationHeader(),
'Content-Type' => 'application/json',
...$headers,
],
'body' => json_encode($data),
]);
return json_decode($response->getBody(), true) ?? [];
}
/**
* Make PUT request with OAuth token
*
* @param array<string, mixed> $data
* @param array<string, string> $headers
* @return array<string, mixed>
*/
public function put(
string $url,
OAuthToken $token,
array $data = [],
array $headers = []
): array {
$response = $this->httpClient->put($url, [
'headers' => [
'Authorization' => $token->getAuthorizationHeader(),
'Content-Type' => 'application/json',
...$headers,
],
'body' => json_encode($data),
]);
return json_decode($response->getBody(), true) ?? [];
}
/**
* Make DELETE request with OAuth token
*
* @param array<string, string> $headers
* @return array<string, mixed>
*/
public function delete(string $url, OAuthToken $token, array $headers = []): array
{
$response = $this->httpClient->delete($url, [
'headers' => [
'Authorization' => $token->getAuthorizationHeader(),
...$headers,
],
]);
return json_decode($response->getBody(), true) ?? [];
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* OAuth Provider Interface
*
* Defines contract for OAuth 2.0 providers (Spotify, Tidal, Google, etc.)
*/
interface OAuthProvider
{
/**
* Get the provider name (e.g., 'spotify', 'tidal')
*/
public function getName(): string;
/**
* Get authorization URL for OAuth flow
*
* @param array<string, mixed> $options Additional options (scope, state, etc.)
*/
public function getAuthorizationUrl(array $options = []): string;
/**
* Exchange authorization code for access token
*/
public function getAccessToken(string $code, ?string $state = null): OAuthToken;
/**
* Refresh an expired access token
*/
public function refreshToken(OAuthToken $token): OAuthToken;
/**
* Revoke a token (logout)
*/
public function revokeToken(OAuthToken $token): bool;
/**
* Get user profile from provider
*
* @return array<string, mixed>
*/
public function getUserProfile(OAuthToken $token): array;
/**
* Get required scopes for this provider
*
* @return array<string>
*/
public function getDefaultScopes(): array;
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\OAuth\Providers\AppleMusicProvider;
use App\Framework\OAuth\Providers\TidalProvider;
use App\Framework\HttpClient\CurlHttpClient;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
/**
* OAuth Provider Initializer
*
* Registers OAuth providers in DI container with configuration from environment
*/
final readonly class OAuthProviderInitializer
{
#[Initializer]
public function initializeOAuthClient(CurlHttpClient $httpClient): OAuthClient
{
return new OAuthClient($httpClient);
}
#[Initializer]
public function initializeSpotifyProvider(
OAuthClient $client,
Environment $env
): SpotifyProvider {
return new SpotifyProvider(
client: $client,
clientId: $env->get(EnvKey::SPOTIFY_CLIENT_ID, 'dev_spotify_client_id'),
clientSecret: $env->get(EnvKey::SPOTIFY_CLIENT_SECRET, 'dev_spotify_client_secret'),
redirectUri: $env->get(
EnvKey::SPOTIFY_REDIRECT_URI,
$env->get(EnvKey::APP_URL, 'https://localhost') . '/oauth/spotify/callback'
)
);
}
#[Initializer]
public function initializeAppleMusicProvider(
OAuthClient $client,
Environment $env
): AppleMusicProvider {
return new AppleMusicProvider(
client: $client,
clientId: $env->require(EnvKey::APPLE_MUSIC_CLIENT_ID),
teamId: $env->require(EnvKey::APPLE_MUSIC_TEAM_ID),
keyId: $env->require(EnvKey::APPLE_MUSIC_KEY_ID),
privateKey: $env->require(EnvKey::APPLE_MUSIC_PRIVATE_KEY),
redirectUri: $env->get(
EnvKey::APPLE_MUSIC_REDIRECT_URI,
$env->get(EnvKey::APP_URL, 'https://localhost') . '/oauth/apple-music/callback'
)
);
}
#[Initializer]
public function initializeTidalProvider(
OAuthClient $client,
Environment $env
): TidalProvider {
return new TidalProvider(
client: $client,
clientId: $env->require(EnvKey::fromString('TIDAL_CLIENT_ID')),
clientSecret: $env->require(EnvKey::fromString('TIDAL_CLIENT_SECRET')),
redirectUri: $env->get(
EnvKey::fromString('TIDAL_REDIRECT_URI'),
$env->get(EnvKey::fromString('APP_URL'), 'https://localhost') . '/oauth/tidal/callback'
)
);
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth;
use App\Framework\OAuth\Storage\OAuthTokenRepository;
use App\Framework\OAuth\Storage\StoredOAuthToken;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* OAuth Service
*
* Central service for OAuth token management with automatic refresh
*/
final readonly class OAuthService
{
/**
* @param array<string, OAuthProvider> $providers
*/
public function __construct(
private OAuthTokenRepository $tokenRepository,
private array $providers = [],
) {}
/**
* Get provider by name
*/
public function getProvider(string $name): OAuthProvider
{
if (!isset($this->providers[$name])) {
throw FrameworkException::create(
ErrorCode::CONFIG_MISSING,
"OAuth provider '{$name}' not configured"
)->withData([
'provider' => $name,
'available_providers' => array_keys($this->providers),
]);
}
return $this->providers[$name];
}
/**
* Get authorization URL for provider
*
* @param array<string, mixed> $options
*/
public function getAuthorizationUrl(string $provider, array $options = []): string
{
return $this->getProvider($provider)->getAuthorizationUrl($options);
}
/**
* Handle OAuth callback and store token
*/
public function handleCallback(
string $userId,
string $provider,
string $code,
?string $state = null
): StoredOAuthToken {
$oauthProvider = $this->getProvider($provider);
$token = $oauthProvider->getAccessToken($code, $state);
return $this->tokenRepository->saveForUser($userId, $provider, $token);
}
/**
* Get token for user with automatic refresh if expired
*/
public function getTokenForUser(string $userId, string $provider): StoredOAuthToken
{
$storedToken = $this->tokenRepository->findForUser($userId, $provider);
if ($storedToken === null) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_NOT_FOUND,
"No OAuth token found for user"
)->withData([
'user_id' => $userId,
'provider' => $provider,
]);
}
// Auto-refresh if expired
if ($storedToken->isExpired() && $storedToken->canRefresh()) {
return $this->refreshToken($storedToken);
}
if ($storedToken->isExpired()) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_EXPIRED,
"OAuth token expired and cannot be refreshed"
)->withData([
'user_id' => $userId,
'provider' => $provider,
'expires_at' => $storedToken->token->getExpiresAt()->format('Y-m-d H:i:s'),
]);
}
return $storedToken;
}
/**
* Refresh an expired token
*/
public function refreshToken(StoredOAuthToken $storedToken): StoredOAuthToken
{
$oauthProvider = $this->getProvider($storedToken->provider);
$newToken = $oauthProvider->refreshToken($storedToken->token);
$updated = $storedToken->withRefreshedToken($newToken);
return $this->tokenRepository->save($updated);
}
/**
* Revoke token and remove from storage
*/
public function revokeToken(string $userId, string $provider): bool
{
$storedToken = $this->tokenRepository->findForUser($userId, $provider);
if ($storedToken === null) {
return true; // Already revoked
}
$oauthProvider = $this->getProvider($provider);
$oauthProvider->revokeToken($storedToken->token);
return $this->tokenRepository->deleteForUser($userId, $provider);
}
/**
* Get user profile from provider
*
* @return array<string, mixed>
*/
public function getUserProfile(string $userId, string $provider): array
{
$storedToken = $this->getTokenForUser($userId, $provider);
$oauthProvider = $this->getProvider($provider);
return $oauthProvider->getUserProfile($storedToken->token);
}
/**
* Check if user has connected a provider
*/
public function hasProvider(string $userId, string $provider): bool
{
return $this->tokenRepository->findForUser($userId, $provider) !== null;
}
/**
* Get all providers for user
*
* @return array<StoredOAuthToken>
*/
public function getUserProviders(string $userId): array
{
return $this->tokenRepository->findAllForUser($userId);
}
/**
* Refresh all expiring tokens (background job)
*/
public function refreshExpiringTokens(int $withinSeconds = 300): int
{
$tokens = $this->tokenRepository->findExpiringSoon($withinSeconds);
$refreshed = 0;
foreach ($tokens as $storedToken) {
try {
$this->refreshToken($storedToken);
$refreshed++;
} catch (\Exception $e) {
// Log but continue with other tokens
error_log("Failed to refresh token for user {$storedToken->userId}: {$e->getMessage()}");
}
}
return $refreshed;
}
/**
* Clean up expired tokens without refresh token (background job)
*/
public function cleanupExpiredTokens(): int
{
return $this->tokenRepository->deleteExpired();
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Providers;
use App\Framework\OAuth\OAuthClient;
use App\Framework\OAuth\OAuthProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Apple Music OAuth Provider
*
* Implements Apple Music API OAuth 2.0 flow (MusicKit)
* @see https://developer.apple.com/documentation/applemusicapi
*/
final readonly class AppleMusicProvider implements OAuthProvider
{
private const AUTHORIZATION_URL = 'https://appleid.apple.com/auth/authorize';
private const TOKEN_URL = 'https://appleid.apple.com/auth/token';
private const API_BASE_URL = 'https://api.music.apple.com/v1';
public function __construct(
private OAuthClient $client,
private string $clientId,
private string $teamId,
private string $keyId,
private string $privateKey,
private string $redirectUri,
) {
if (empty($clientId) || empty($teamId) || empty($keyId) || empty($privateKey)) {
throw FrameworkException::create(
ErrorCode::CONFIG_MISSING,
'Apple Music OAuth credentials not configured'
)->withData([
'client_id_set' => !empty($clientId),
'team_id_set' => !empty($teamId),
'key_id_set' => !empty($keyId),
'private_key_set' => !empty($privateKey),
]);
}
}
public function getName(): string
{
return 'apple_music';
}
/**
* Get default scopes for Apple Music
*
* @return array<string>
*/
public function getDefaultScopes(): array
{
return [
'music-user-library-modify',
'music-user-library-read',
];
}
public function getAuthorizationUrl(array $options = []): string
{
$scopes = $options['scope'] ?? $this->getDefaultScopes();
$state = $options['state'] ?? bin2hex(random_bytes(16));
$params = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'response_mode' => 'form_post',
'redirect_uri' => $this->redirectUri,
'scope' => is_array($scopes) ? implode(' ', $scopes) : $scopes,
'state' => $state,
]);
return self::AUTHORIZATION_URL . '?' . $params;
}
public function getAccessToken(string $code, ?string $state = null): OAuthToken
{
// Apple Music uses JWT for authentication
$clientSecret = $this->generateClientSecret();
$response = $this->client->post(self::TOKEN_URL, [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri,
'client_id' => $this->clientId,
'client_secret' => $clientSecret,
]);
if (!isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_EXCHANGE_FAILED,
'Failed to exchange code for access token'
)->withData([
'provider' => 'apple_music',
'error' => $response['error'] ?? 'unknown',
'error_description' => $response['error_description'] ?? null,
]);
}
return OAuthToken::fromProviderResponse($response);
}
public function refreshToken(OAuthToken $token): OAuthToken
{
if (!$token->canRefresh()) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Token cannot be refreshed (no refresh token)'
)->withData([
'provider' => 'apple_music',
]);
}
$clientSecret = $this->generateClientSecret();
$response = $this->client->post(self::TOKEN_URL, [
'grant_type' => 'refresh_token',
'refresh_token' => $token->refreshToken->toString(),
'client_id' => $this->clientId,
'client_secret' => $clientSecret,
]);
if (!isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Failed to refresh access token'
)->withData([
'provider' => 'apple_music',
'error' => $response['error'] ?? 'unknown',
]);
}
return OAuthToken::fromProviderResponse($response);
}
public function revokeToken(OAuthToken $token): bool
{
// Apple Music token revocation
$clientSecret = $this->generateClientSecret();
$this->client->post('https://appleid.apple.com/auth/revoke', [
'token' => $token->accessToken->toString(),
'client_id' => $this->clientId,
'client_secret' => $clientSecret,
'token_type_hint' => 'access_token',
]);
return true;
}
/**
* Get user profile from Apple Music
*
* @return array<string, mixed>
*/
public function getUserProfile(OAuthToken $token): array
{
try {
return $this->client->get(self::API_BASE_URL . '/me/account', $token);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::EXTERNAL_API_ERROR,
'Failed to fetch Apple Music user profile'
)->withData([
'provider' => 'apple_music',
'error' => $e->getMessage(),
]);
}
}
/**
* Get user storefront (required for API calls)
*
* @return array<string, mixed>
*/
public function getUserStorefront(OAuthToken $token): array
{
try {
return $this->client->get(self::API_BASE_URL . '/me/storefront', $token);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::EXTERNAL_API_ERROR,
'Failed to fetch Apple Music user storefront'
)->withData([
'provider' => 'apple_music',
'error' => $e->getMessage(),
]);
}
}
/**
* Add tracks to user's library (for pre-save campaigns)
*
* @param array<string> $trackIds Apple Music track IDs
*/
public function addTracksToLibrary(OAuthToken $token, array $trackIds): bool
{
if (empty($trackIds)) {
return true;
}
// Apple Music allows multiple tracks in one request
$this->client->post(
self::API_BASE_URL . '/me/library',
$token,
[
'ids' => $trackIds,
'type' => 'songs',
]
);
return true;
}
/**
* Extract track ID from Apple Music URL
*
* Examples:
* - https://music.apple.com/us/album/song-name/1234567890?i=9876543210
* - https://music.apple.com/album/1234567890
*/
public function extractTrackId(string $trackUrl): string
{
// Try to extract track ID from i= parameter
if (preg_match('/[?&]i=(\d+)/', $trackUrl, $matches)) {
return $matches[1];
}
// Try to extract album ID
if (preg_match('/album\/[^\/]+\/(\d+)/', $trackUrl, $matches)) {
return $matches[1];
}
throw new \InvalidArgumentException('Invalid Apple Music track URL: ' . $trackUrl);
}
/**
* Generate JWT client secret for Apple Music API
*
* Apple requires a JWT signed with the private key
*/
private function generateClientSecret(): string
{
$header = [
'alg' => 'ES256',
'kid' => $this->keyId,
];
$now = time();
$payload = [
'iss' => $this->teamId,
'iat' => $now,
'exp' => $now + 3600, // 1 hour
'aud' => 'https://appleid.apple.com',
'sub' => $this->clientId,
];
$headerEncoded = $this->base64UrlEncode(json_encode($header));
$payloadEncoded = $this->base64UrlEncode(json_encode($payload));
$signature = '';
$dataToSign = $headerEncoded . '.' . $payloadEncoded;
// Sign with ES256 (ECDSA using P-256 and SHA-256)
openssl_sign(
$dataToSign,
$signature,
$this->privateKey,
OPENSSL_ALGO_SHA256
);
$signatureEncoded = $this->base64UrlEncode($signature);
return $dataToSign . '.' . $signatureEncoded;
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Providers;
use App\Framework\OAuth\OAuthClient;
use App\Framework\OAuth\OAuthProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Spotify OAuth Provider
*
* Implements Spotify Web API OAuth 2.0 flow
* @see https://developer.spotify.com/documentation/web-api/concepts/authorization
*/
final readonly class SpotifyProvider implements OAuthProvider
{
private const AUTHORIZATION_URL = 'https://accounts.spotify.com/authorize';
private const TOKEN_URL = 'https://accounts.spotify.com/api/token';
private const PROFILE_URL = 'https://api.spotify.com/v1/me';
public function __construct(
private OAuthClient $client,
private string $clientId,
private string $clientSecret,
private string $redirectUri,
) {
if (empty($clientId) || empty($clientSecret)) {
throw FrameworkException::create(
ErrorCode::CONFIG_MISSING,
'Spotify OAuth credentials not configured'
)->withData([
'client_id_set' => !empty($clientId),
'client_secret_set' => !empty($clientSecret),
]);
}
}
public function getName(): string
{
return 'spotify';
}
/**
* Get default scopes for Spotify
*
* Common scopes for pre-save campaigns:
* - user-library-read: Read user's saved tracks
* - user-library-modify: Add tracks to user's library
* - user-read-email: Read user's email (for identification)
*
* @return array<string>
*/
public function getDefaultScopes(): array
{
return [
'user-library-read',
'user-library-modify',
'user-read-email',
];
}
public function getAuthorizationUrl(array $options = []): string
{
$scopes = $options['scope'] ?? $this->getDefaultScopes();
$state = $options['state'] ?? bin2hex(random_bytes(16));
$params = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'scope' => is_array($scopes) ? implode(' ', $scopes) : $scopes,
'state' => $state,
'show_dialog' => $options['show_dialog'] ?? false,
]);
return self::AUTHORIZATION_URL . '?' . $params;
}
public function getAccessToken(string $code, ?string $state = null): OAuthToken
{
$response = $this->client->post(self::TOKEN_URL, [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_EXCHANGE_FAILED,
'Failed to exchange code for access token'
)->withData([
'provider' => 'spotify',
'error' => $response['error'] ?? 'unknown',
'error_description' => $response['error_description'] ?? null,
]);
}
return OAuthToken::fromProviderResponse($response);
}
public function refreshToken(OAuthToken $token): OAuthToken
{
if (!$token->canRefresh()) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Token cannot be refreshed (no refresh token)'
)->withData([
'provider' => 'spotify',
]);
}
$response = $this->client->post(self::TOKEN_URL, [
'grant_type' => 'refresh_token',
'refresh_token' => $token->refreshToken->toString(),
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Failed to refresh access token'
)->withData([
'provider' => 'spotify',
'error' => $response['error'] ?? 'unknown',
]);
}
// Spotify may or may not return a new refresh token
$newToken = OAuthToken::fromProviderResponse($response);
// If no new refresh token, keep the old one
if ($newToken->refreshToken === null) {
return $newToken->withRefreshedAccessToken(
$newToken->accessToken,
$token->refreshToken
);
}
return $newToken;
}
public function revokeToken(OAuthToken $token): bool
{
// Spotify doesn't provide a token revocation endpoint
// Token will expire naturally or can be revoked from Spotify account settings
return true;
}
/**
* Get user profile from Spotify
*
* @return array<string, mixed>
*/
public function getUserProfile(OAuthToken $token): array
{
try {
return $this->client->get(self::PROFILE_URL, $token);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::EXTERNAL_API_ERROR,
'Failed to fetch Spotify user profile'
)->withData([
'provider' => 'spotify',
'error' => $e->getMessage(),
]);
}
}
/**
* Add track to user's library (for pre-save campaigns)
*
* @param array<string> $trackIds Spotify track IDs
*/
public function addTracksToLibrary(OAuthToken $token, array $trackIds): bool
{
if (empty($trackIds)) {
return true;
}
// Spotify API allows max 50 tracks per request
$chunks = array_chunk($trackIds, 50);
foreach ($chunks as $chunk) {
$this->client->put(
'https://api.spotify.com/v1/me/tracks',
$token,
['ids' => $chunk]
);
}
return true;
}
/**
* Check if tracks are in user's library
*
* @param array<string> $trackIds Spotify track IDs (max 50)
* @return array<bool>
*/
public function checkTracksInLibrary(OAuthToken $token, array $trackIds): array
{
if (empty($trackIds)) {
return [];
}
if (count($trackIds) > 50) {
throw new \InvalidArgumentException('Max 50 track IDs allowed per request');
}
$params = http_build_query(['ids' => implode(',', $trackIds)]);
$url = 'https://api.spotify.com/v1/me/tracks/contains?' . $params;
return $this->client->get($url, $token);
}
}

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Providers;
use App\Framework\OAuth\OAuthClient;
use App\Framework\OAuth\OAuthProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Tidal OAuth Provider
*
* Implements Tidal API OAuth 2.0 flow for pre-save campaigns
* @see https://developer.tidal.com/documentation/api/api-overview
*/
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';
private const API_BASE_URL = 'https://api.tidal.com/v1';
public function __construct(
private OAuthClient $client,
private string $clientId,
private string $clientSecret,
private string $redirectUri,
) {
if (empty($clientId) || empty($clientSecret)) {
throw FrameworkException::create(
ErrorCode::CONFIG_MISSING,
'Tidal OAuth credentials not configured'
)->withData([
'client_id_set' => !empty($clientId),
'client_secret_set' => !empty($clientSecret),
]);
}
}
public function getName(): string
{
return 'tidal';
}
/**
* Get default scopes for Tidal
*
* Scopes for pre-save campaigns:
* - r_usr: Read user data
* - w_usr: Write user data (add to favorites)
*
* @return array<string>
*/
public function getDefaultScopes(): array
{
return [
'r_usr',
'w_usr',
];
}
public function getAuthorizationUrl(array $options = []): string
{
$scopes = $options['scope'] ?? $this->getDefaultScopes();
$state = $options['state'] ?? bin2hex(random_bytes(16));
$params = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'scope' => is_array($scopes) ? implode(' ', $scopes) : $scopes,
'state' => $state,
]);
return self::AUTHORIZATION_URL . '?' . $params;
}
public function getAccessToken(string $code, ?string $state = null): OAuthToken
{
$response = $this->client->post(self::TOKEN_URL, [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_EXCHANGE_FAILED,
'Failed to exchange code for access token'
)->withData([
'provider' => 'tidal',
'error' => $response['error'] ?? 'unknown',
'error_description' => $response['error_description'] ?? null,
]);
}
return OAuthToken::fromProviderResponse($response);
}
public function refreshToken(OAuthToken $token): OAuthToken
{
if (!$token->canRefresh()) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Token cannot be refreshed (no refresh token)'
)->withData([
'provider' => 'tidal',
]);
}
$response = $this->client->post(self::TOKEN_URL, [
'grant_type' => 'refresh_token',
'refresh_token' => $token->refreshToken->toString(),
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Failed to refresh access token'
)->withData([
'provider' => 'tidal',
'error' => $response['error'] ?? 'unknown',
]);
}
$newToken = OAuthToken::fromProviderResponse($response);
// If no new refresh token, keep the old one
if ($newToken->refreshToken === null) {
return $newToken->withRefreshedAccessToken(
$newToken->accessToken,
$token->refreshToken
);
}
return $newToken;
}
public function revokeToken(OAuthToken $token): bool
{
// Tidal token revocation would be implemented here if available
return true;
}
/**
* Get user profile from Tidal
*
* @return array<string, mixed>
*/
public function getUserProfile(OAuthToken $token): array
{
try {
return $this->client->get(self::API_BASE_URL . '/users/me', $token);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::EXTERNAL_API_ERROR,
'Failed to fetch Tidal user profile'
)->withData([
'provider' => 'tidal',
'error' => $e->getMessage(),
]);
}
}
/**
* Add tracks to user's favorites (for pre-save campaigns)
*
* @param array<string> $trackIds Tidal track IDs
*/
public function addTracksToFavorites(OAuthToken $token, array $trackIds): bool
{
if (empty($trackIds)) {
return true;
}
// Get user ID for favorites endpoint
$profile = $this->getUserProfile($token);
$userId = $profile['userId'] ?? $profile['id'] ?? null;
if (!$userId) {
throw FrameworkException::create(
ErrorCode::EXTERNAL_API_ERROR,
'Could not determine Tidal user ID'
);
}
// Add tracks to favorites
foreach ($trackIds as $trackId) {
$this->client->put(
self::API_BASE_URL . "/users/{$userId}/favorites/tracks",
$token,
['trackIds' => [$trackId]]
);
}
return true;
}
/**
* Extract track ID from Tidal URL
*
* Examples:
* - https://tidal.com/browse/track/12345678
* - https://listen.tidal.com/track/12345678
*/
public function extractTrackId(string $trackUrl): string
{
if (preg_match('/track\/(\d+)/', $trackUrl, $matches)) {
return $matches[1];
}
throw new \InvalidArgumentException('Invalid Tidal track URL: ' . $trackUrl);
}
}

View File

@@ -0,0 +1,539 @@
# 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

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Storage;
use App\Framework\Database\ConnectionInterface;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* OAuth Token Repository
*
* Handles persistence of OAuth tokens with automatic refresh handling
*/
final readonly class OAuthTokenRepository
{
private const TABLE = 'oauth_tokens';
public function __construct(
private ConnectionInterface $connection,
) {}
/**
* Find token for user and provider
*/
public function findForUser(string $userId, string $provider): ?StoredOAuthToken
{
$sql = "SELECT * FROM " . self::TABLE . "
WHERE user_id = ? AND provider = ?
LIMIT 1";
$row = $this->connection->fetch($sql, [$userId, $provider]);
return $row ? StoredOAuthToken::fromArray($row) : null;
}
/**
* Find token by ID
*/
public function findById(int $id): ?StoredOAuthToken
{
$sql = "SELECT * FROM " . self::TABLE . " WHERE id = ? LIMIT 1";
$row = $this->connection->fetch($sql, [$id]);
return $row ? StoredOAuthToken::fromArray($row) : null;
}
/**
* Save or update token
*/
public function save(StoredOAuthToken $storedToken): StoredOAuthToken
{
if ($storedToken->id === null) {
return $this->insert($storedToken);
}
return $this->update($storedToken);
}
/**
* Save token for user (upsert by user_id + provider)
*/
public function saveForUser(
string $userId,
string $provider,
OAuthToken $token
): StoredOAuthToken {
$existing = $this->findForUser($userId, $provider);
if ($existing !== null) {
$updated = $existing->withRefreshedToken($token);
return $this->update($updated);
}
$storedToken = StoredOAuthToken::create($userId, $provider, $token);
return $this->insert($storedToken);
}
/**
* Delete token for user and provider
*/
public function deleteForUser(string $userId, string $provider): bool
{
$sql = "DELETE FROM " . self::TABLE . " WHERE user_id = ? AND provider = ?";
$this->connection->execute($sql, [$userId, $provider]);
return true;
}
/**
* Delete expired tokens (cleanup job)
*/
public function deleteExpired(): int
{
$sql = "DELETE FROM " . self::TABLE . "
WHERE expires_at < ? AND refresh_token IS NULL";
$this->connection->execute($sql, [time()]);
return $this->connection->rowCount();
}
/**
* Get all tokens for a user
*
* @return array<StoredOAuthToken>
*/
public function findAllForUser(string $userId): array
{
$sql = "SELECT * FROM " . self::TABLE . " WHERE user_id = ?";
$rows = $this->connection->fetchAll($sql, [$userId]);
return array_map(
fn(array $row) => StoredOAuthToken::fromArray($row),
$rows
);
}
/**
* Get all tokens expiring soon (for refresh worker)
*
* @return array<StoredOAuthToken>
*/
public function findExpiringSoon(int $withinSeconds = 300): array
{
$expiryThreshold = time() + $withinSeconds;
$sql = "SELECT * FROM " . self::TABLE . "
WHERE expires_at < ?
AND refresh_token IS NOT NULL";
$rows = $this->connection->fetchAll($sql, [$expiryThreshold]);
return array_map(
fn(array $row) => StoredOAuthToken::fromArray($row),
$rows
);
}
/**
* Insert new token
*/
private function insert(StoredOAuthToken $storedToken): StoredOAuthToken
{
$data = $storedToken->toArray();
unset($data['id']); // Don't insert ID
$sql = "INSERT INTO " . self::TABLE . "
(user_id, provider, access_token, refresh_token, token_type, scope, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
$this->connection->execute($sql, [
$data['user_id'],
$data['provider'],
$data['access_token'],
$data['refresh_token'],
$data['token_type'],
$data['scope'],
$data['expires_at'],
$data['created_at'],
$data['updated_at'],
]);
$id = (int) $this->connection->lastInsertId();
return new StoredOAuthToken(
id: $id,
userId: $storedToken->userId,
provider: $storedToken->provider,
token: $storedToken->token,
createdAt: $storedToken->createdAt,
updatedAt: $storedToken->updatedAt,
);
}
/**
* Update existing token
*/
private function update(StoredOAuthToken $storedToken): StoredOAuthToken
{
if ($storedToken->id === null) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
'Cannot update token without ID'
);
}
$data = $storedToken->toArray();
$sql = "UPDATE " . self::TABLE . "
SET access_token = ?,
refresh_token = ?,
token_type = ?,
scope = ?,
expires_at = ?,
updated_at = ?
WHERE id = ?";
$this->connection->execute($sql, [
$data['access_token'],
$data['refresh_token'],
$data['token_type'],
$data['scope'],
$data['expires_at'],
$data['updated_at'],
$storedToken->id,
]);
return $storedToken;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Storage;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Stored OAuth Token Entity
*
* Represents a persisted OAuth token with user and provider association
*/
final readonly class StoredOAuthToken
{
public function __construct(
public ?int $id,
public string $userId,
public string $provider,
public OAuthToken $token,
public Timestamp $createdAt,
public Timestamp $updatedAt,
) {}
/**
* Create new stored token
*/
public static function create(
string $userId,
string $provider,
OAuthToken $token
): self {
$now = Timestamp::now();
return new self(
id: null,
userId: $userId,
provider: $provider,
token: $token,
createdAt: $now,
updatedAt: $now,
);
}
/**
* Update with refreshed token
*/
public function withRefreshedToken(OAuthToken $token): self
{
return new self(
id: $this->id,
userId: $this->userId,
provider: $this->provider,
token: $token,
createdAt: $this->createdAt,
updatedAt: Timestamp::now(),
);
}
/**
* Check if token is expired
*/
public function isExpired(): bool
{
return $this->token->isExpired();
}
/**
* Check if token can be refreshed
*/
public function canRefresh(): bool
{
return $this->token->canRefresh();
}
/**
* Convert to array for storage
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'user_id' => $this->userId,
'provider' => $this->provider,
'access_token' => $this->token->accessToken->toString(),
'refresh_token' => $this->token->refreshToken?->toString(),
'token_type' => $this->token->tokenType->value,
'scope' => $this->token->scope?->toString(),
'expires_at' => $this->token->getExpiresAt()->getTimestamp(),
'created_at' => $this->createdAt->getTimestamp(),
'updated_at' => $this->updatedAt->getTimestamp(),
];
}
/**
* Create from database row
*
* @param array<string, mixed> $row
*/
public static function fromArray(array $row): self
{
return new self(
id: isset($row['id']) ? (int) $row['id'] : null,
userId: (string) $row['user_id'],
provider: (string) $row['provider'],
token: OAuthToken::fromArray([
'access_token' => $row['access_token'],
'refresh_token' => $row['refresh_token'] ?? null,
'token_type' => $row['token_type'] ?? 'Bearer',
'scope' => $row['scope'] ?? null,
'expires_at' => (int) $row['expires_at'],
]),
createdAt: Timestamp::fromUnix((int) $row['created_at']),
updatedAt: Timestamp::fromUnix((int) $row['updated_at']),
);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\ValueObjects;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Access Token Value Object
*
* Represents an OAuth access token with validation and masking
*/
final readonly class AccessToken
{
public function __construct(
private string $token,
private Timestamp $expiresAt,
) {
if (empty($token)) {
throw new \InvalidArgumentException('Access token cannot be empty');
}
if (strlen($token) < 10) {
throw new \InvalidArgumentException('Access token appears invalid (too short)');
}
}
public static function create(string $token, Timestamp $expiresAt): self
{
return new self($token, $expiresAt);
}
/**
* Create from provider response with expires_in seconds
*/
public static function fromProviderResponse(string $token, int $expiresIn): self
{
return new self(
$token,
Timestamp::now()->addSeconds($expiresIn)
);
}
/**
* Get the raw token value
*/
public function toString(): string
{
return $this->token;
}
/**
* Get expiration timestamp
*/
public function getExpiresAt(): Timestamp
{
return $this->expiresAt;
}
/**
* Check if token is expired
*/
public function isExpired(): bool
{
// Add 60 second buffer for network latency
return $this->expiresAt->isBefore(Timestamp::now()->addSeconds(60));
}
/**
* Check if token is still valid
*/
public function isValid(): bool
{
return !$this->isExpired();
}
/**
* Get seconds until expiration
*/
public function getSecondsUntilExpiration(): int
{
$now = Timestamp::now();
if ($this->expiresAt->isBefore($now)) {
return 0;
}
return $this->expiresAt->getTimestamp() - $now->getTimestamp();
}
/**
* Get masked token for logging (shows first/last 4 chars)
*/
public function getMasked(): string
{
if (strlen($this->token) <= 12) {
return str_repeat('*', strlen($this->token));
}
$first = substr($this->token, 0, 4);
$last = substr($this->token, -4);
$masked = str_repeat('*', strlen($this->token) - 8);
return $first . $masked . $last;
}
/**
* Create new token with updated expiration
*/
public function withExpiresAt(Timestamp $expiresAt): self
{
return new self($this->token, $expiresAt);
}
public function __toString(): string
{
return $this->getMasked();
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\ValueObjects;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* OAuth Token Value Object
*
* Immutable composite representation of OAuth access/refresh tokens
* Uses Value Objects for maximum type safety
*/
final readonly class OAuthToken
{
public function __construct(
public AccessToken $accessToken,
public ?RefreshToken $refreshToken,
public TokenType $tokenType,
public ?TokenScope $scope,
) {}
/**
* Create from provider response
*
* @param array<string, mixed> $response
*/
public static function fromProviderResponse(array $response): self
{
if (!isset($response['access_token'])) {
throw new \InvalidArgumentException('Provider response missing access_token');
}
$expiresIn = (int) ($response['expires_in'] ?? 3600);
return new self(
accessToken: AccessToken::fromProviderResponse(
$response['access_token'],
$expiresIn
),
refreshToken: isset($response['refresh_token'])
? RefreshToken::create($response['refresh_token'])
: null,
tokenType: TokenType::fromString($response['token_type'] ?? 'Bearer'),
scope: isset($response['scope']) && !empty($response['scope'])
? TokenScope::fromString($response['scope'])
: null,
);
}
/**
* Check if token is expired
*/
public function isExpired(): bool
{
return $this->accessToken->isExpired();
}
/**
* Check if token is still valid
*/
public function isValid(): bool
{
return $this->accessToken->isValid();
}
/**
* Check if token can be refreshed
*/
public function canRefresh(): bool
{
return $this->refreshToken !== null;
}
/**
* Get authorization header value
*/
public function getAuthorizationHeader(): string
{
return $this->tokenType->getHeaderPrefix() . ' ' . $this->accessToken->toString();
}
/**
* Get seconds until expiration
*/
public function getSecondsUntilExpiration(): int
{
return $this->accessToken->getSecondsUntilExpiration();
}
/**
* Get expiration timestamp
*/
public function getExpiresAt(): Timestamp
{
return $this->accessToken->getExpiresAt();
}
/**
* Check if token has specific scope
*/
public function hasScope(string $scope): bool
{
return $this->scope !== null && $this->scope->includes($scope);
}
/**
* Check if token has all required scopes
*
* @param array<string> $requiredScopes
*/
public function hasScopes(array $requiredScopes): bool
{
return $this->scope !== null && $this->scope->includesAll($requiredScopes);
}
/**
* Create new token with refreshed access token
*/
public function withRefreshedAccessToken(
AccessToken $accessToken,
?RefreshToken $refreshToken = null
): self {
return new self(
accessToken: $accessToken,
refreshToken: $refreshToken ?? $this->refreshToken,
tokenType: $this->tokenType,
scope: $this->scope,
);
}
/**
* Convert to array for storage
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'access_token' => $this->accessToken->toString(),
'refresh_token' => $this->refreshToken?->toString(),
'expires_at' => $this->accessToken->getExpiresAt()->getTimestamp(),
'token_type' => $this->tokenType->value,
'scope' => $this->scope?->toString(),
];
}
/**
* Convert to array for logging (with masked tokens)
*
* @return array<string, mixed>
*/
public function toArrayMasked(): array
{
return [
'access_token' => $this->accessToken->getMasked(),
'refresh_token' => $this->refreshToken?->getMasked(),
'expires_at' => $this->accessToken->getExpiresAt()->format('Y-m-d H:i:s'),
'expires_in' => $this->getSecondsUntilExpiration() . 's',
'token_type' => $this->tokenType->value,
'scope' => $this->scope?->toString(),
'is_expired' => $this->isExpired(),
'can_refresh' => $this->canRefresh(),
];
}
/**
* Create from stored array
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
if (!isset($data['access_token'], $data['expires_at'])) {
throw new \InvalidArgumentException('Stored token data missing required fields');
}
return new self(
accessToken: AccessToken::create(
$data['access_token'],
Timestamp::fromUnix((int) $data['expires_at'])
),
refreshToken: isset($data['refresh_token'])
? RefreshToken::create($data['refresh_token'])
: null,
tokenType: TokenType::fromString($data['token_type'] ?? 'Bearer'),
scope: isset($data['scope']) && !empty($data['scope'])
? TokenScope::fromString($data['scope'])
: null,
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\ValueObjects;
/**
* Refresh Token Value Object
*
* Represents an OAuth refresh token with validation and masking
*/
final readonly class RefreshToken
{
public function __construct(
private string $token,
) {
if (empty($token)) {
throw new \InvalidArgumentException('Refresh token cannot be empty');
}
if (strlen($token) < 10) {
throw new \InvalidArgumentException('Refresh token appears invalid (too short)');
}
}
public static function create(string $token): self
{
return new self($token);
}
/**
* Get the raw token value
*/
public function toString(): string
{
return $this->token;
}
/**
* Get masked token for logging (shows first/last 4 chars)
*/
public function getMasked(): string
{
if (strlen($this->token) <= 12) {
return str_repeat('*', strlen($this->token));
}
$first = substr($this->token, 0, 4);
$last = substr($this->token, -4);
$masked = str_repeat('*', strlen($this->token) - 8);
return $first . $masked . $last;
}
public function __toString(): string
{
return $this->getMasked();
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\ValueObjects;
/**
* OAuth Token Scope Value Object
*
* Represents OAuth scopes (space-separated as per RFC 6749)
*/
final readonly class TokenScope
{
/**
* @param array<string> $scopes
*/
public function __construct(
private array $scopes,
) {
if (empty($scopes)) {
throw new \InvalidArgumentException('Token scope cannot be empty');
}
foreach ($scopes as $scope) {
if (!is_string($scope) || empty(trim($scope))) {
throw new \InvalidArgumentException('Invalid scope value');
}
}
}
/**
* Create from space-separated string (OAuth standard format)
*/
public static function fromString(string $scopeString): self
{
$scopes = array_filter(
array_map('trim', explode(' ', $scopeString)),
fn($scope) => !empty($scope)
);
return new self($scopes);
}
/**
* Create from array of scopes
*
* @param array<string> $scopes
*/
public static function fromArray(array $scopes): self
{
return new self($scopes);
}
/**
* Get scopes as array
*
* @return array<string>
*/
public function toArray(): array
{
return $this->scopes;
}
/**
* Get scopes as space-separated string (OAuth standard format)
*/
public function toString(): string
{
return implode(' ', $this->scopes);
}
/**
* Check if scope includes a specific permission
*/
public function includes(string $scope): bool
{
return in_array($scope, $this->scopes, true);
}
/**
* Check if scope includes all specified permissions
*
* @param array<string> $requiredScopes
*/
public function includesAll(array $requiredScopes): bool
{
foreach ($requiredScopes as $required) {
if (!$this->includes($required)) {
return false;
}
}
return true;
}
/**
* Check if scope includes any of the specified permissions
*
* @param array<string> $scopes
*/
public function includesAny(array $scopes): bool
{
foreach ($scopes as $scope) {
if ($this->includes($scope)) {
return true;
}
}
return false;
}
/**
* Add additional scopes
*
* @param array<string> $additionalScopes
*/
public function withAdditional(array $additionalScopes): self
{
return new self([...$this->scopes, ...$additionalScopes]);
}
/**
* Remove specific scopes
*
* @param array<string> $scopesToRemove
*/
public function without(array $scopesToRemove): self
{
$filtered = array_filter(
$this->scopes,
fn($scope) => !in_array($scope, $scopesToRemove, true)
);
if (empty($filtered)) {
throw new \InvalidArgumentException('Cannot remove all scopes');
}
return new self(array_values($filtered));
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\ValueObjects;
/**
* OAuth Token Type Enum
*
* RFC 6749 compliant token types with fallback for custom implementations
*/
enum TokenType: string
{
case BEARER = 'Bearer';
case MAC = 'MAC';
/**
* Create from string with fallback to Bearer
*/
public static function fromString(string $type): self
{
return match (strtolower(trim($type))) {
'bearer' => self::BEARER,
'mac' => self::MAC,
default => self::BEARER, // Fallback for unknown types
};
}
/**
* Get authorization header prefix
*/
public function getHeaderPrefix(): string
{
return $this->value;
}
/**
* Check if this is Bearer token
*/
public function isBearer(): bool
{
return $this === self::BEARER;
}
/**
* Check if this is MAC token
*/
public function isMac(): bool
{
return $this === self::MAC;
}
}