feat(Production): Complete production deployment infrastructure

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

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -16,7 +16,8 @@ final readonly class OAuthClient
{
public function __construct(
private HttpClient $httpClient,
) {}
) {
}
/**
* Make GET request to OAuth provider

View File

@@ -4,14 +4,14 @@ 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;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\HttpClient\CurlHttpClient;
use App\Framework\OAuth\Providers\AppleMusicProvider;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\OAuth\Providers\TidalProvider;
/**
* OAuth Provider Initializer

View File

@@ -4,35 +4,35 @@ 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;
use App\Framework\Exception\FrameworkException;
use App\Framework\OAuth\Storage\OAuthTokenRepositoryInterface;
use App\Framework\OAuth\Storage\StoredOAuthToken;
/**
* OAuth Service
*
* Central service for OAuth token management with automatic refresh
*/
final readonly class OAuthService
final readonly class OAuthService implements OAuthServiceInterface
{
/**
* @param array<string, OAuthProvider> $providers
*/
public function __construct(
private OAuthTokenRepository $tokenRepository,
private OAuthTokenRepositoryInterface $tokenRepository,
private array $providers = [],
) {}
) {
}
/**
* Get provider by name
*/
public function getProvider(string $name): OAuthProvider
{
if (!isset($this->providers[$name])) {
if (! isset($this->providers[$name])) {
throw FrameworkException::create(
ErrorCode::CONFIG_MISSING,
ErrorCode::SYSTEM_CONFIG_MISSING,
"OAuth provider '{$name}' not configured"
)->withData([
'provider' => $name,
@@ -77,7 +77,7 @@ final readonly class OAuthService
if ($storedToken === null) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_NOT_FOUND,
ErrorCode::ENTITY_NOT_FOUND,
"No OAuth token found for user"
)->withData([
'user_id' => $userId,
@@ -113,6 +113,7 @@ final readonly class OAuthService
$newToken = $oauthProvider->refreshToken($storedToken->token);
$updated = $storedToken->withRefreshedToken($newToken);
return $this->tokenRepository->save($updated);
}
@@ -190,6 +191,6 @@ final readonly class OAuthService
*/
public function cleanupExpiredTokens(): int
{
return $this->tokenRepository->deleteExpired();
return $this->tokenRepository->deleteExpiredWithoutRefreshToken();
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth;
use App\Framework\OAuth\Storage\StoredOAuthToken;
/**
* OAuth Service Interface
*
* Contract for OAuth token management services
*/
interface OAuthServiceInterface
{
/**
* Get provider by name
*/
public function getProvider(string $name): OAuthProvider;
/**
* Get authorization URL for provider
*
* @param array<string, mixed> $options
*/
public function getAuthorizationUrl(string $provider, array $options = []): string;
/**
* Handle OAuth callback and store token
*/
public function handleCallback(
string $userId,
string $provider,
string $code,
?string $state = null
): StoredOAuthToken;
/**
* Get token for user with automatic refresh if expired
*/
public function getTokenForUser(string $userId, string $provider): StoredOAuthToken;
/**
* Refresh an expired token
*/
public function refreshToken(StoredOAuthToken $storedToken): StoredOAuthToken;
/**
* Revoke token and remove from storage
*/
public function revokeToken(string $userId, string $provider): bool;
/**
* Get user profile from provider
*
* @return array<string, mixed>
*/
public function getUserProfile(string $userId, string $provider): array;
/**
* Check if user has connected a provider
*/
public function hasProvider(string $userId, string $provider): bool;
/**
* Get all providers for user
*
* @return array<StoredOAuthToken>
*/
public function getUserProviders(string $userId): array;
/**
* Refresh all expiring tokens (background job)
*/
public function refreshExpiringTokens(int $withinSeconds = 300): int;
/**
* Clean up expired tokens without refresh token (background job)
*/
public function cleanupExpiredTokens(): int;
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\OAuth\Providers;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
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
@@ -35,10 +35,10 @@ final readonly class AppleMusicProvider implements OAuthProvider
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),
'client_id_set' => ! empty($clientId),
'team_id_set' => ! empty($teamId),
'key_id_set' => ! empty($keyId),
'private_key_set' => ! empty($privateKey),
]);
}
}
@@ -91,7 +91,7 @@ final readonly class AppleMusicProvider implements OAuthProvider
'client_secret' => $clientSecret,
]);
if (!isset($response['access_token'])) {
if (! isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_EXCHANGE_FAILED,
'Failed to exchange code for access token'
@@ -107,7 +107,7 @@ final readonly class AppleMusicProvider implements OAuthProvider
public function refreshToken(OAuthToken $token): OAuthToken
{
if (!$token->canRefresh()) {
if (! $token->canRefresh()) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Token cannot be refreshed (no refresh token)'
@@ -125,7 +125,7 @@ final readonly class AppleMusicProvider implements OAuthProvider
'client_secret' => $clientSecret,
]);
if (!isset($response['access_token'])) {
if (! isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Failed to refresh access token'

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\OAuth\Providers;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
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
@@ -16,7 +16,7 @@ use App\Framework\Exception\ErrorCode;
* Implements Spotify Web API OAuth 2.0 flow
* @see https://developer.spotify.com/documentation/web-api/concepts/authorization
*/
final readonly class SpotifyProvider implements OAuthProvider
final readonly class SpotifyProvider implements OAuthProvider, SupportsPreSaves
{
private const AUTHORIZATION_URL = 'https://accounts.spotify.com/authorize';
private const TOKEN_URL = 'https://accounts.spotify.com/api/token';
@@ -33,8 +33,8 @@ final readonly class SpotifyProvider implements OAuthProvider
ErrorCode::CONFIG_MISSING,
'Spotify OAuth credentials not configured'
)->withData([
'client_id_set' => !empty($clientId),
'client_secret_set' => !empty($clientSecret),
'client_id_set' => ! empty($clientId),
'client_secret_set' => ! empty($clientSecret),
]);
}
}
@@ -90,7 +90,7 @@ final readonly class SpotifyProvider implements OAuthProvider
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
if (! isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_EXCHANGE_FAILED,
'Failed to exchange code for access token'
@@ -106,7 +106,7 @@ final readonly class SpotifyProvider implements OAuthProvider
public function refreshToken(OAuthToken $token): OAuthToken
{
if (!$token->canRefresh()) {
if (! $token->canRefresh()) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Token cannot be refreshed (no refresh token)'
@@ -122,7 +122,7 @@ final readonly class SpotifyProvider implements OAuthProvider
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
if (! isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Failed to refresh access token'

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Providers;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* Supports PreSaves Interface
*
* Marker interface for OAuth providers that support pre-save campaign functionality
* (adding tracks to user's library before official release)
*/
interface SupportsPreSaves
{
/**
* Add tracks to user's library
*
* @param array<string> $trackIds Platform-specific track IDs
*/
public function addTracksToLibrary(OAuthToken $token, array $trackIds): bool;
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\OAuth\Providers;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
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
@@ -33,8 +33,8 @@ final readonly class TidalProvider implements OAuthProvider
ErrorCode::CONFIG_MISSING,
'Tidal OAuth credentials not configured'
)->withData([
'client_id_set' => !empty($clientId),
'client_secret_set' => !empty($clientSecret),
'client_id_set' => ! empty($clientId),
'client_secret_set' => ! empty($clientSecret),
]);
}
}
@@ -87,7 +87,7 @@ final readonly class TidalProvider implements OAuthProvider
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
if (! isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_EXCHANGE_FAILED,
'Failed to exchange code for access token'
@@ -103,7 +103,7 @@ final readonly class TidalProvider implements OAuthProvider
public function refreshToken(OAuthToken $token): OAuthToken
{
if (!$token->canRefresh()) {
if (! $token->canRefresh()) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Token cannot be refreshed (no refresh token)'
@@ -119,7 +119,7 @@ final readonly class TidalProvider implements OAuthProvider
'client_secret' => $this->clientSecret,
]);
if (!isset($response['access_token'])) {
if (! isset($response['access_token'])) {
throw FrameworkException::create(
ErrorCode::AUTH_TOKEN_REFRESH_FAILED,
'Failed to refresh access token'
@@ -183,7 +183,7 @@ final readonly class TidalProvider implements OAuthProvider
$profile = $this->getUserProfile($token);
$userId = $profile['userId'] ?? $profile['id'] ?? null;
if (!$userId) {
if (! $userId) {
throw FrameworkException::create(
ErrorCode::EXTERNAL_API_ERROR,
'Could not determine Tidal user ID'

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Storage;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* In-Memory OAuth Token Repository
*
* Test implementation for OAuth token persistence
*/
final class InMemoryOAuthTokenRepository implements OAuthTokenRepositoryInterface
{
/** @var array<int, StoredOAuthToken> */
private array $tokens = [];
private int $nextId = 1;
public function findForUser(string $userId, string $provider): ?StoredOAuthToken
{
foreach ($this->tokens as $token) {
if ($token->userId === $userId && $token->provider === $provider) {
return $token;
}
}
return null;
}
public function findById(int $id): ?StoredOAuthToken
{
return $this->tokens[$id] ?? null;
}
public function save(StoredOAuthToken $storedToken): StoredOAuthToken
{
if ($storedToken->id === null) {
return $this->insert($storedToken);
}
return $this->update($storedToken);
}
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);
}
public function deleteForUser(string $userId, string $provider): bool
{
foreach ($this->tokens as $id => $token) {
if ($token->userId === $userId && $token->provider === $provider) {
unset($this->tokens[$id]);
return true;
}
}
return false;
}
public function deleteExpiredWithoutRefreshToken(): int
{
$deleted = 0;
$now = Timestamp::now();
foreach ($this->tokens as $id => $token) {
if ($token->isExpired() && !$token->canRefresh()) {
unset($this->tokens[$id]);
$deleted++;
}
}
return $deleted;
}
public function findAllForUser(string $userId): array
{
$result = [];
foreach ($this->tokens as $token) {
if ($token->userId === $userId) {
$result[] = $token;
}
}
return $result;
}
public function findExpiringSoon(int $withinSeconds = 300): array
{
$result = [];
$now = Timestamp::now();
$threshold = $now->toTimestamp() + $withinSeconds;
foreach ($this->tokens as $token) {
$expiresAt = $token->token->accessToken->getExpiresAt()->toTimestamp();
if ($expiresAt < $threshold && $token->canRefresh()) {
$result[] = $token;
}
}
return $result;
}
/**
* Clear all tokens (test utility)
*/
public function clear(): void
{
$this->tokens = [];
$this->nextId = 1;
}
/**
* Get all tokens (test utility)
*
* @return array<StoredOAuthToken>
*/
public function all(): array
{
return array_values($this->tokens);
}
/**
* Insert new token
*/
private function insert(StoredOAuthToken $storedToken): StoredOAuthToken
{
$id = $this->nextId++;
$persisted = new StoredOAuthToken(
id: $id,
userId: $storedToken->userId,
provider: $storedToken->provider,
token: $storedToken->token,
createdAt: $storedToken->createdAt,
updatedAt: $storedToken->updatedAt,
);
$this->tokens[$id] = $persisted;
return $persisted;
}
/**
* Update existing token
*/
private function update(StoredOAuthToken $storedToken): StoredOAuthToken
{
if ($storedToken->id === null) {
throw new \InvalidArgumentException('Cannot update token without ID');
}
if (!isset($this->tokens[$storedToken->id])) {
throw new \InvalidArgumentException('Token not found: ' . $storedToken->id);
}
// Create updated token with new timestamp
$updated = new StoredOAuthToken(
id: $storedToken->id,
userId: $storedToken->userId,
provider: $storedToken->provider,
token: $storedToken->token,
createdAt: $storedToken->createdAt,
updatedAt: Timestamp::now(),
);
$this->tokens[$storedToken->id] = $updated;
return $updated;
}
}

View File

@@ -5,22 +5,23 @@ 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;
use App\Framework\Exception\FrameworkException;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* OAuth Token Repository
*
* Handles persistence of OAuth tokens with automatic refresh handling
*/
final readonly class OAuthTokenRepository
final readonly class OAuthTokenRepository implements OAuthTokenRepositoryInterface
{
private const TABLE = 'oauth_tokens';
public function __construct(
private ConnectionInterface $connection,
) {}
) {
}
/**
* Find token for user and provider
@@ -71,10 +72,12 @@ final readonly class OAuthTokenRepository
if ($existing !== null) {
$updated = $existing->withRefreshedToken($token);
return $this->update($updated);
}
$storedToken = StoredOAuthToken::create($userId, $provider, $token);
return $this->insert($storedToken);
}
@@ -90,9 +93,9 @@ final readonly class OAuthTokenRepository
}
/**
* Delete expired tokens (cleanup job)
* Delete expired tokens without refresh token (cleanup job)
*/
public function deleteExpired(): int
public function deleteExpiredWithoutRefreshToken(): int
{
$sql = "DELETE FROM " . self::TABLE . "
WHERE expires_at < ? AND refresh_token IS NULL";
@@ -113,7 +116,7 @@ final readonly class OAuthTokenRepository
$rows = $this->connection->fetchAll($sql, [$userId]);
return array_map(
fn(array $row) => StoredOAuthToken::fromArray($row),
fn (array $row) => StoredOAuthToken::fromArray($row),
$rows
);
}
@@ -134,7 +137,7 @@ final readonly class OAuthTokenRepository
$rows = $this->connection->fetchAll($sql, [$expiryThreshold]);
return array_map(
fn(array $row) => StoredOAuthToken::fromArray($row),
fn (array $row) => StoredOAuthToken::fromArray($row),
$rows
);
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\OAuth\Storage;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* OAuth Token Repository Interface
*
* Defines contract for OAuth token persistence
*/
interface OAuthTokenRepositoryInterface
{
/**
* Find token for user and provider
*/
public function findForUser(string $userId, string $provider): ?StoredOAuthToken;
/**
* Find token by ID
*/
public function findById(int $id): ?StoredOAuthToken;
/**
* Save or update token
*/
public function save(StoredOAuthToken $storedToken): StoredOAuthToken;
/**
* Save token for user (upsert by user_id + provider)
*/
public function saveForUser(
string $userId,
string $provider,
OAuthToken $token
): StoredOAuthToken;
/**
* Delete token for user and provider
*/
public function deleteForUser(string $userId, string $provider): bool;
/**
* Delete expired tokens without refresh token (cleanup job)
*
* @return int Number of deleted tokens
*/
public function deleteExpiredWithoutRefreshToken(): int;
/**
* Get all tokens for a user
*
* @return array<StoredOAuthToken>
*/
public function findAllForUser(string $userId): array;
/**
* Get all tokens expiring soon (for refresh worker)
*
* @param int $withinSeconds Tokens expiring within this many seconds
* @return array<StoredOAuthToken>
*/
public function findExpiringSoon(int $withinSeconds = 300): array;
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\OAuth\Storage;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* Stored OAuth Token Entity
@@ -21,7 +21,8 @@ final readonly class StoredOAuthToken
public OAuthToken $token,
public Timestamp $createdAt,
public Timestamp $updatedAt,
) {}
) {
}
/**
* Create new stored token
@@ -81,18 +82,23 @@ final readonly class StoredOAuthToken
*/
public function toArray(): array
{
return [
'id' => $this->id,
$array = [
'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(),
'expires_at' => $this->token->getExpiresAt()->toTimestamp(),
'created_at' => $this->createdAt->toTimestamp(),
'updated_at' => $this->updatedAt->toTimestamp(),
];
if ($this->id !== null) {
$array['id'] = $this->id;
}
return $array;
}
/**
@@ -102,6 +108,19 @@ final readonly class StoredOAuthToken
*/
public static function fromArray(array $row): self
{
// Convert timestamp fields if they're in string format
$expiresAt = is_numeric($row['expires_at'])
? (int) $row['expires_at']
: strtotime($row['expires_at']);
$createdAt = is_numeric($row['created_at'])
? (int) $row['created_at']
: strtotime($row['created_at']);
$updatedAt = is_numeric($row['updated_at'])
? (int) $row['updated_at']
: strtotime($row['updated_at']);
return new self(
id: isset($row['id']) ? (int) $row['id'] : null,
userId: (string) $row['user_id'],
@@ -111,10 +130,10 @@ final readonly class StoredOAuthToken
'refresh_token' => $row['refresh_token'] ?? null,
'token_type' => $row['token_type'] ?? 'Bearer',
'scope' => $row['scope'] ?? null,
'expires_at' => (int) $row['expires_at'],
'expires_at' => $expiresAt,
]),
createdAt: Timestamp::fromUnix((int) $row['created_at']),
updatedAt: Timestamp::fromUnix((int) $row['updated_at']),
createdAt: Timestamp::fromTimestamp($createdAt),
updatedAt: Timestamp::fromTimestamp($updatedAt),
);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\OAuth\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
@@ -38,7 +39,7 @@ final readonly class AccessToken
{
return new self(
$token,
Timestamp::now()->addSeconds($expiresIn)
Timestamp::now()->add(Duration::fromSeconds($expiresIn))
);
}
@@ -64,7 +65,7 @@ final readonly class AccessToken
public function isExpired(): bool
{
// Add 60 second buffer for network latency
return $this->expiresAt->isBefore(Timestamp::now()->addSeconds(60));
return $this->expiresAt->isBefore(Timestamp::now()->add(Duration::fromSeconds(60)));
}
/**
@@ -72,7 +73,7 @@ final readonly class AccessToken
*/
public function isValid(): bool
{
return !$this->isExpired();
return ! $this->isExpired();
}
/**
@@ -85,7 +86,7 @@ final readonly class AccessToken
return 0;
}
return $this->expiresAt->getTimestamp() - $now->getTimestamp();
return $this->expiresAt->toTimestamp() - $now->toTimestamp();
}
/**

View File

@@ -19,7 +19,8 @@ final readonly class OAuthToken
public ?RefreshToken $refreshToken,
public TokenType $tokenType,
public ?TokenScope $scope,
) {}
) {
}
/**
* Create from provider response
@@ -28,7 +29,7 @@ final readonly class OAuthToken
*/
public static function fromProviderResponse(array $response): self
{
if (!isset($response['access_token'])) {
if (! isset($response['access_token'])) {
throw new \InvalidArgumentException('Provider response missing access_token');
}
@@ -43,7 +44,7 @@ final readonly class OAuthToken
? RefreshToken::create($response['refresh_token'])
: null,
tokenType: TokenType::fromString($response['token_type'] ?? 'Bearer'),
scope: isset($response['scope']) && !empty($response['scope'])
scope: isset($response['scope']) && ! empty($response['scope'])
? TokenScope::fromString($response['scope'])
: null,
);
@@ -137,13 +138,21 @@ final readonly class OAuthToken
*/
public function toArray(): array
{
return [
$array = [
'access_token' => $this->accessToken->toString(),
'refresh_token' => $this->refreshToken?->toString(),
'expires_at' => $this->accessToken->getExpiresAt()->getTimestamp(),
'expires_at' => $this->accessToken->getExpiresAt()->toTimestamp(),
'token_type' => $this->tokenType->value,
'scope' => $this->scope?->toString(),
];
if ($this->refreshToken !== null) {
$array['refresh_token'] = $this->refreshToken->toString();
}
if ($this->scope !== null) {
$array['scope'] = $this->scope->toString();
}
return $array;
}
/**
@@ -172,20 +181,20 @@ final readonly class OAuthToken
*/
public static function fromArray(array $data): self
{
if (!isset($data['access_token'], $data['expires_at'])) {
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'])
Timestamp::fromTimestamp((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'])
scope: isset($data['scope']) && ! empty($data['scope'])
? TokenScope::fromString($data['scope'])
: null,
);

View File

@@ -22,7 +22,7 @@ final readonly class TokenScope
}
foreach ($scopes as $scope) {
if (!is_string($scope) || empty(trim($scope))) {
if (! is_string($scope) || empty(trim($scope))) {
throw new \InvalidArgumentException('Invalid scope value');
}
}
@@ -33,10 +33,10 @@ final readonly class TokenScope
*/
public static function fromString(string $scopeString): self
{
$scopes = array_filter(
$scopes = array_values(array_filter(
array_map('trim', explode(' ', $scopeString)),
fn($scope) => !empty($scope)
);
fn ($scope) => ! empty($scope)
));
return new self($scopes);
}
@@ -85,7 +85,7 @@ final readonly class TokenScope
public function includesAll(array $requiredScopes): bool
{
foreach ($requiredScopes as $required) {
if (!$this->includes($required)) {
if (! $this->includes($required)) {
return false;
}
}
@@ -128,7 +128,7 @@ final readonly class TokenScope
{
$filtered = array_filter(
$this->scopes,
fn($scope) => !in_array($scope, $scopesToRemove, true)
fn ($scope) => ! in_array($scope, $scopesToRemove, true)
);
if (empty($filtered)) {