Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/Factory/ServiceAuthorizationRepositoryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Horde\Core\Factory;

use Horde\Core\Service\NullServiceAuthorizationRepository;
use Horde\Core\Service\ServiceAuthorizationRepository;
use Horde\Injector\Injector;

/** Factory for ServiceAuthorizationRepository - defaults to NullServiceAuthorizationRepository. */
class ServiceAuthorizationRepositoryFactory
{
public function create(Injector $injector): ServiceAuthorizationRepository
{
return new NullServiceAuthorizationRepository();
}
}
18 changes: 18 additions & 0 deletions src/Factory/ServiceAuthorizationServiceFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Horde\Core\Factory;

use Horde\Core\Service\NullServiceAuthorizationService;
use Horde\Core\Service\ServiceAuthorizationService;
use Horde\Injector\Injector;

/** Factory for ServiceAuthorizationService - defaults to NullServiceAuthorizationService. */
class ServiceAuthorizationServiceFactory
{
public function create(Injector $injector): ServiceAuthorizationService
{
return new NullServiceAuthorizationService();
}
}
18 changes: 18 additions & 0 deletions src/Factory/TokenGrantRepositoryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Horde\Core\Factory;

use Horde\Core\Service\NullTokenGrantRepository;
use Horde\Core\Service\TokenGrantRepository;
use Horde\Injector\Injector;

/** Factory for TokenGrantRepository - defaults to NullTokenGrantRepository. */
class TokenGrantRepositoryFactory
{
public function create(Injector $injector): TokenGrantRepository
{
return new NullTokenGrantRepository();
}
}
30 changes: 29 additions & 1 deletion src/Service/Exception/OAuthTokenRefreshException.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,39 @@

namespace Horde\Core\Service\Exception;

use Horde\Core\Service\TokenGrant;
use RuntimeException;

/**
* Thrown when an OAuth token refresh fails (e.g. refresh token revoked).
*
* Callers should prompt the user to re-authorize.
*/
class OAuthTokenRefreshException extends RuntimeException {}
class OAuthTokenRefreshException extends RuntimeException
{
public function __construct(
string $message,
private readonly string $userId,
private readonly string $providerId,
private readonly ?TokenGrant $grant = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}

public function userId(): string
{
return $this->userId;
}

public function providerId(): string
{
return $this->providerId;
}

public function grant(): ?TokenGrant
{
return $this->grant;
}
}
22 changes: 22 additions & 0 deletions src/Service/GrantStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Horde\Core\Service;

enum GrantStrategy: string
{
/**
* Extend an existing shared TokenGrant for this (userId, providerId).
* The new required scopes are added to the union. The updated grant
* backs all ServiceAuthorizations for this provider.
*/
case Additive = 'additive';

/**
* Create a new, independent TokenGrant carrying only the scopes
* required for this purpose. No existing grant is modified.
* This is the default case.
*/
case Isolated = 'isolated';
}
33 changes: 33 additions & 0 deletions src/Service/NullServiceAuthorizationRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Horde\Core\Service;

/** Null ServiceAuthorizationRepository - safe default when service authorization is not configured. */
class NullServiceAuthorizationRepository implements ServiceAuthorizationRepository
{
public function find(
string $userId,
string $providerId,
ServicePurpose $purpose,
): ?ServiceAuthorization {
return null;
}

public function findAll(string $userId, string $providerId): array
{
return [];
}

public function findAllForUser(string $userId): array
{
return [];
}

public function save(ServiceAuthorization $authorization): void {}

public function delete(ServiceAuthorization $authorization): void {}

public function deleteAll(string $userId, string $providerId): void {}
}
53 changes: 53 additions & 0 deletions src/Service/NullServiceAuthorizationService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Horde\Core\Service;

use Horde\OAuth\Client\OAuthFlowData;
use Horde\OAuth\Client\ScopeSet;
use Psr\Http\Message\UriInterface;

/** Null ServiceAuthorizationService - safe default when service authorization is not configured. */
class NullServiceAuthorizationService implements ServiceAuthorizationService
{
public function get(
string $userId,
string $providerId,
ServicePurpose $purpose,
): ServiceAuthorization {
throw new ServiceNotAuthorizedException(
$userId,
$providerId,
$purpose,
new ScopeSet()
);
}

public function initiate(
string $userId,
string $providerId,
ServicePurpose $purpose,
string $returnUrl,
?string $requestingApp = null,
): ?UriInterface {
return null;
}

public function handleCallback(string $code, OAuthFlowData $flowData): ServiceAuthorization
{
throw new \RuntimeException('Service authorization not configured');
}

public function revoke(
string $userId,
string $providerId,
ServicePurpose $purpose,
): void {}

public function revokeAll(
string $userId,
string $providerId,
bool $revokeAtProvider = false,
): void {}
}
30 changes: 30 additions & 0 deletions src/Service/NullTokenGrantRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Horde\Core\Service;

/** Null TokenGrantRepository - safe default when service authorization is not configured. */
class NullTokenGrantRepository implements TokenGrantRepository
{
public function findShared(string $userId, string $providerId): ?TokenGrant
{
return null;
}

public function findById(string $grantId): ?TokenGrant
{
return null;
}

public function findAll(string $userId, string $providerId): array
{
return [];
}

public function save(TokenGrant $grant): void {}

public function update(TokenGrant $grant): void {}

public function delete(TokenGrant $grant): void {}
}
2 changes: 1 addition & 1 deletion src/Service/OAuthProviderConfigRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* Storage contract for OAuth provider configurations.
*
* Providers are keyed by a unique slug (provider_id).
* Data is returned as plain arrays with snake_case keys matching DB columns,
* Data is returned as plain arrays with snake_case keys matching DB columns
* because the three provider types (oauth2, oidc, service_app) have
* different shapes.
*
Expand Down
29 changes: 29 additions & 0 deletions src/Service/ServiceAuthorization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);

namespace Horde\Core\Service;
/**
* Revocation is performed through `ServiceAuthorizationService` rather than through this object.
* This avoids the object holding a self-reference to its repository.
*/
interface ServiceAuthorization
{
public function userId(): string;
public function providerId(): string;
public function purpose(): ServicePurpose;
public function grant(): TokenGrant;

/**
* True if the backing grant is non-null and covers the scopes required
* for this purpose. Does not perform a network call.
*/
public function isSatisfied(): bool;

/**
* Return a valid access token delegating to grant()->getAccessToken().
*
* @throws ServiceNotAuthorizedException when !isSatisfied()
* @throws OAuthTokenRefreshException when the grant refresh fails
*/
public function getAccessToken(): string;
}
26 changes: 26 additions & 0 deletions src/Service/ServiceAuthorizationRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);

namespace Horde\Core\Service;

interface ServiceAuthorizationRepository
{
public function find(
string $userId,
string $providerId,
ServicePurpose $purpose,
): ?ServiceAuthorization;

/** All authorizations for a (userId, providerId) pair. */
public function findAll(string $userId, string $providerId): array;

/** All authorizations for a user across all providers. */
public function findAllForUser(string $userId): array;

public function save(ServiceAuthorization $authorization): void;

public function delete(ServiceAuthorization $authorization): void;

/** Remove all authorizations for a (userId, providerId) pair. */
public function deleteAll(string $userId, string $providerId): void;
}
74 changes: 74 additions & 0 deletions src/Service/ServiceAuthorizationService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);

namespace Horde\Core\Service;

use Psr\Http\Message\UriInterface;

interface ServiceAuthorizationService
{
/**
* Return an existing and satisfied ServiceAuthorization.
*
* @throws ServiceNotAuthorizedException no authorization or grant does
* not cover the required scopes.
* The exception carries the purpose
* and providerId. The caller can react by calling initiate().
*/
public function get(
string $userId,
string $providerId,
ServicePurpose $purpose,
): ServiceAuthorization;

/**
* Initiate an OAuth2 PKCE flow to establish a ServiceAuthorization.
*
* Additive strategy: if a shared grant already covers the required scopes,
* creates the ServiceAuthorization directly and returns null (no redirect).
*
* Isolated strategy: always starts a new flow. Preferred strategy.
*
* @throws UnsupportedPurposeException the provider has no scope mapping
* for this purpose.
* @return UriInterface|null provider redirect URI, or null if no flow needed.
*/
public function initiate(
string $userId,
string $providerId,
ServicePurpose $purpose,
string $returnUrl,
?string $requestingApp = null,
): ?UriInterface;

/**
* Handle the provider callback after PKCE state verification.
* Creates or updates the TokenGrant and creates the ServiceAuthorization.
* Does NOT call IdentityLinkService as that remains the caller's
* responsibility for 'login' and 'account_link' purposes.
*/
public function handleCallback(string $code, OAuthFlowData $flowData): ServiceAuthorization;

/**
* Revoke a single ServiceAuthorization.
* The backing TokenGrant is not deleted.
*/
public function revoke(
string $userId,
string $providerId,
ServicePurpose $purpose,
): void;

/**
* Revoke all ServiceAuthorizations for a provider and delete all backing
* TokenGrants.
*
* @param bool $revokeAtProvider True: Call the provider's revocation
* endpoint for each grant before deletion.
*/
public function revokeAll(
string $userId,
string $providerId,
bool $revokeAtProvider = false,
): void;
}
Loading
Loading