diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27eff37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/.phpunit.cache/ diff --git a/EXTRACTION.md b/EXTRACTION.md new file mode 100644 index 0000000..e5a19a0 --- /dev/null +++ b/EXTRACTION.md @@ -0,0 +1,33 @@ +# Extraction Checklist + +This package currently lives inside the `gnexus-auth` repository only as a temporary development location. + +Target state: + +- separate private git repository; +- package root becomes the repository root; +- Composer install through private `vcs` repository. + +## Extraction Steps + +1. Create a new private repository for the package. +2. Copy contents of `packages/auth-client` into the new repository root. +3. Keep package name `gnexus/auth-client`. +4. Keep namespace `GNexus\GAuth`. +5. Run package tests in the new repository. +6. Add CI for: + - syntax lint + - PHPUnit +7. Tag the first private version. +8. Connect consuming services through Composer `repositories.type = vcs`. + +## Before First Extraction + +Recommended cleanup before moving: + +- add package changelog; +- add stricter test coverage for HTTP mapping and webhook parsing edge cases; +- decide first stable constructor signatures; +- add at least one real PSR-18 integration test path; +- document minimal supported PSR implementations for consumers. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a604630 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# gnexus/auth-client + +Private framework-agnostic PHP client library for integrating services with `gnexus-auth`. + +## Status + +Early scaffold with working core pieces. + +Current scaffold includes: + +- package metadata; +- PSR-oriented dependency boundaries; +- high-level `GAuthClient`; +- configuration object; +- DTO set; +- storage and service contracts; +- exception family; +- PKCE and authorization URL helpers. +- HTTP token endpoint client; +- HTTP userinfo client; +- webhook HMAC verifier; +- webhook JSON parser; +- package-level unit tests; +- plain PHP integration example. + +This package is intended to be extracted into a separate private repository. + +## Current Path + +Temporary local development path: + +```text +packages/auth-client +``` + +## Usage Shape + +The package is designed around one high-level client: + +```php +GNexus\GAuth\Client\GAuthClient +``` + +It expects: + +- `GAuthConfig` +- token endpoint implementation +- runtime user provider +- webhook verifier +- webhook parser +- state store +- PKCE store + +## Example + +A plain PHP integration example is included at: + +```text +examples/plain-php +``` + +It shows: + +- redirect to `gnexus-auth` +- callback exchange +- userinfo fetch +- webhook verification and parsing +- session-backed example stores + +A Composer consumer example is included at: + +```text +examples/consumer-template +``` + +It shows: + +- private `vcs` repository wiring +- package requirement +- one practical set of PSR implementations for HTTP and message factories + +## Extraction + +This package is meant to be moved into a dedicated private repository. + +See: + +- `examples/plain-php/README.md` +- `EXTRACTION.md` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ce2e8ac --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "gnexus/auth-client", + "description": "Framework-agnostic PHP client library for gnexus-auth integrations", + "type": "library", + "license": "proprietary", + "require": { + "php": "^8.3", + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0", + "psr/log": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "autoload": { + "psr-4": { + "GNexus\\GAuth\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "GNexus\\GAuth\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} + diff --git a/examples/consumer-template/README.md b/examples/consumer-template/README.md new file mode 100644 index 0000000..b392132 --- /dev/null +++ b/examples/consumer-template/README.md @@ -0,0 +1,16 @@ +# Consumer Template + +This example shows how a client service can require `gnexus/auth-client` from a private git repository through Composer. + +It is not tied to Laravel. The same package requirement shape can be used in any PHP application. + +## Files + +- `composer.json` - minimal dependency wiring + +## Notes + +- replace the repository URL with your private git URL; +- adjust the version constraint to your tagging strategy; +- the listed PSR implementations are a practical default, not a hard requirement. + diff --git a/examples/consumer-template/composer.json b/examples/consumer-template/composer.json new file mode 100644 index 0000000..bc60998 --- /dev/null +++ b/examples/consumer-template/composer.json @@ -0,0 +1,14 @@ +{ + "repositories": [ + { + "type": "vcs", + "url": "ssh://git@git.example.com/gnexus/auth-client.git" + } + ], + "require": { + "php": "^8.3", + "gnexus/auth-client": "^0.1", + "guzzlehttp/guzzle": "^7.9", + "nyholm/psr7": "^1.8" + } +} diff --git a/examples/plain-php/README.md b/examples/plain-php/README.md new file mode 100644 index 0000000..4079717 --- /dev/null +++ b/examples/plain-php/README.md @@ -0,0 +1,44 @@ +# Plain PHP Example + +This example demonstrates the intended usage shape of `gnexus/auth-client` in a minimal non-framework PHP application. + +It is not production-ready. + +## What It Shows + +- redirecting a user to `gnexus-auth` +- handling the authorization callback +- exchanging code for tokens +- fetching the current user +- verifying and parsing webhooks +- storing state, PKCE data and tokens in native PHP session + +## Files + +- `bootstrap.php` - package construction +- `stores.php` - session-backed example stores +- `index.php` - start authorization flow +- `callback.php` - handle authorization callback +- `webhook.php` - verify and parse webhook request + +## Requirements + +The example assumes you install compatible PSR implementations in the real package repository, for example: + +- PSR-18 HTTP client +- PSR-17 request factory +- PSR-17 stream factory + +The example code uses placeholders for these objects. Wire them with the implementations your project uses. + +## Runtime Notes + +- call `session_start()` before using the example stores; +- do not use the session stores as-is for distributed production systems; +- use persistent shared storage if your application runs on multiple nodes. + +## Related Example + +If you need a minimal `composer.json` for a consuming service, see: + +- `../consumer-template` diff --git a/examples/plain-php/bootstrap.php b/examples/plain-php/bootstrap.php new file mode 100644 index 0000000..8160922 --- /dev/null +++ b/examples/plain-php/bootstrap.php @@ -0,0 +1,53 @@ +exchangeAuthorizationCode($code, $state); + $user = $client->fetchUser($tokenSet->accessToken); +} catch (GAuthException $exception) { + http_response_code(401); + echo 'Authorization failed: ' . $exception->getMessage(); + exit; +} + +$_SESSION['gauth_access_token'] = $tokenSet->accessToken; +$_SESSION['gauth_refresh_token'] = $tokenSet->refreshToken; +$_SESSION['gauth_user_id'] = $user->userId; +$_SESSION['gauth_user_email'] = $user->email; + +header('Location: /protected.php'); +exit; + diff --git a/examples/plain-php/index.php b/examples/plain-php/index.php new file mode 100644 index 0000000..08fa9aa --- /dev/null +++ b/examples/plain-php/index.php @@ -0,0 +1,15 @@ +buildAuthorizationRequest( + returnTo: '/protected.php', + scopes: ['openid', 'email', 'profile', 'roles', 'permissions'], +); + +header('Location: ' . $authorizationRequest->authorizationUrl); +exit; + diff --git a/examples/plain-php/stores.php b/examples/plain-php/stores.php new file mode 100644 index 0000000..0f36413 --- /dev/null +++ b/examples/plain-php/stores.php @@ -0,0 +1,82 @@ + $expiresAt->format(DateTimeInterface::ATOM), + 'context' => $context, + ]; + } + + public function has(string $state): bool + { + $record = $_SESSION['gauth_state'][$state] ?? null; + + if (! is_array($record)) { + return false; + } + + if (new DateTimeImmutable($record['expires_at']) < new DateTimeImmutable()) { + unset($_SESSION['gauth_state'][$state]); + + return false; + } + + return true; + } + + public function getContext(string $state): array + { + if (! $this->has($state)) { + return []; + } + + return $_SESSION['gauth_state'][$state]['context'] ?? []; + } + + public function forget(string $state): void + { + unset($_SESSION['gauth_state'][$state]); + } +} + +final class SessionPkceStore implements PkceStoreInterface +{ + public function put(string $state, string $verifier, DateTimeImmutable $expiresAt): void + { + $_SESSION['gauth_pkce'][$state] = [ + 'verifier' => $verifier, + 'expires_at' => $expiresAt->format(DateTimeInterface::ATOM), + ]; + } + + public function get(string $state): ?string + { + $record = $_SESSION['gauth_pkce'][$state] ?? null; + + if (! is_array($record)) { + return null; + } + + if (new DateTimeImmutable($record['expires_at']) < new DateTimeImmutable()) { + unset($_SESSION['gauth_pkce'][$state]); + + return null; + } + + return isset($record['verifier']) ? (string) $record['verifier'] : null; + } + + public function forget(string $state): void + { + unset($_SESSION['gauth_pkce'][$state]); + } +} + diff --git a/examples/plain-php/webhook.php b/examples/plain-php/webhook.php new file mode 100644 index 0000000..53ef34d --- /dev/null +++ b/examples/plain-php/webhook.php @@ -0,0 +1,34 @@ +verifyAndParseWebhook($rawBody, getallheaders(), $secret); +} catch (WebhookVerificationException $exception) { + http_response_code(401); + echo 'Invalid webhook signature.'; + exit; +} + +http_response_code(202); +header('Content-Type: application/json'); +echo json_encode([ + 'accepted' => true, + 'event_type' => $event->eventType, + 'event_id' => $event->eventId, +], JSON_THROW_ON_ERROR); + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..4f57f7f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/src/Client/GAuthClient.php b/src/Client/GAuthClient.php new file mode 100644 index 0000000..1648a80 --- /dev/null +++ b/src/Client/GAuthClient.php @@ -0,0 +1,129 @@ +clock = $clock ?? new SystemClock(); + $this->authorizationUrlBuilder = $authorizationUrlBuilder ?? new AuthorizationUrlBuilder($config); + } + + public function buildAuthorizationRequest(?string $returnTo = null, array $scopes = []): AuthorizationRequest + { + $state = PkceGenerator::generateState(); + $verifier = PkceGenerator::generateVerifier(); + $challenge = PkceGenerator::generateChallenge($verifier); + $expiresAt = $this->clock->now()->modify(sprintf('+%d seconds', $this->config->stateTtlSeconds())); + + $this->stateStore->put($state, $expiresAt, [ + 'return_to' => $returnTo, + 'scopes' => array_values($scopes), + ]); + + $this->pkceStore->put($state, $verifier, $expiresAt); + + $url = $this->authorizationUrlBuilder->build( + state: $state, + pkceChallenge: $challenge, + returnTo: $returnTo, + scopes: $scopes, + ); + + return new AuthorizationRequest( + authorizationUrl: $url, + state: $state, + pkceVerifier: $verifier, + pkceChallenge: $challenge, + scopes: array_values($scopes), + returnTo: $returnTo, + ); + } + + public function exchangeAuthorizationCode(string $code, string $state): TokenSet + { + if (! $this->stateStore->has($state)) { + throw new StateValidationException('Unknown or expired authorization state.'); + } + + $verifier = $this->pkceStore->get($state); + + if ($verifier === null || $verifier === '') { + throw new PkceException('Missing PKCE verifier for authorization callback.'); + } + + $tokenSet = $this->tokenEndpoint->exchangeAuthorizationCode($code, $verifier); + + $this->stateStore->forget($state); + $this->pkceStore->forget($state); + + return $tokenSet; + } + + public function refreshToken(string $refreshToken): TokenSet + { + return $this->tokenEndpoint->refreshToken($refreshToken); + } + + public function revokeToken(string $token, ?string $tokenTypeHint = null): void + { + $this->tokenEndpoint->revokeToken($token, $tokenTypeHint); + } + + public function fetchUser(string $accessToken): AuthenticatedUser + { + return $this->runtimeUserProvider->fetchUser($accessToken); + } + + public function verifyWebhook(string $rawBody, array $headers, string $secret): VerifiedWebhook + { + return $this->webhookVerifier->verify($rawBody, $headers, $secret); + } + + public function parseWebhook(string $rawBody): WebhookEvent + { + return $this->webhookParser->parse($rawBody); + } + + public function verifyAndParseWebhook(string $rawBody, array $headers, string $secret): WebhookEvent + { + $this->verifyWebhook($rawBody, $headers, $secret); + + return $this->parseWebhook($rawBody); + } +} + diff --git a/src/Config/GAuthConfig.php b/src/Config/GAuthConfig.php new file mode 100644 index 0000000..b9c5af6 --- /dev/null +++ b/src/Config/GAuthConfig.php @@ -0,0 +1,162 @@ +baseUrl = $baseUrl; + $this->clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUri = $redirectUri; + $this->authorizePath = $authorizePath; + $this->tokenPath = $tokenPath; + $this->refreshPath = $refreshPath; + $this->revokePath = $revokePath; + $this->userInfoPath = $userInfoPath; + $this->stateTtlSeconds = $stateTtlSeconds; + $this->webhookToleranceSeconds = $webhookToleranceSeconds; + $this->userAgent = $userAgent; + } + + public function baseUrl(): string + { + return $this->baseUrl; + } + + public function clientId(): string + { + return $this->clientId; + } + + public function clientSecret(): string + { + return $this->clientSecret; + } + + public function redirectUri(): string + { + return $this->redirectUri; + } + + public function authorizePath(): string + { + return $this->authorizePath; + } + + public function tokenPath(): string + { + return $this->tokenPath; + } + + public function revokePath(): string + { + return $this->revokePath; + } + + public function refreshPath(): string + { + return $this->refreshPath; + } + + public function userInfoPath(): string + { + return $this->userInfoPath; + } + + public function stateTtlSeconds(): int + { + return $this->stateTtlSeconds; + } + + public function userAgent(): ?string + { + return $this->userAgent; + } + + public function webhookToleranceSeconds(): int + { + return $this->webhookToleranceSeconds; + } + + public function authorizeUrl(): string + { + return $this->baseUrl . $this->authorizePath; + } + + public function tokenUrl(): string + { + return $this->baseUrl . $this->tokenPath; + } + + public function revokeUrl(): string + { + return $this->baseUrl . $this->revokePath; + } + + public function refreshUrl(): string + { + return $this->baseUrl . $this->refreshPath; + } + + public function userInfoUrl(): string + { + return $this->baseUrl . $this->userInfoPath; + } +} diff --git a/src/Contract/ClockInterface.php b/src/Contract/ClockInterface.php new file mode 100644 index 0000000..d732fca --- /dev/null +++ b/src/Contract/ClockInterface.php @@ -0,0 +1,11 @@ + $profile + * @param array $clientAccessList + * @param array $rawPayload + */ + public function __construct( + public string $userId, + public string $email, + public bool $emailVerified, + public ?string $systemRole = null, + public ?string $status = null, + public array $profile = [], + public array $clientAccessList = [], + public array $rawPayload = [], + ) { + } +} + diff --git a/src/DTO/AuthorizationRequest.php b/src/DTO/AuthorizationRequest.php new file mode 100644 index 0000000..650ea99 --- /dev/null +++ b/src/DTO/AuthorizationRequest.php @@ -0,0 +1,22 @@ + $scopes + */ + public function __construct( + public string $authorizationUrl, + public string $state, + public string $pkceVerifier, + public string $pkceChallenge, + public array $scopes = [], + public ?string $returnTo = null, + ) { + } +} + diff --git a/src/DTO/ClientAccess.php b/src/DTO/ClientAccess.php new file mode 100644 index 0000000..7310003 --- /dev/null +++ b/src/DTO/ClientAccess.php @@ -0,0 +1,21 @@ + $roleIds + * @param array $permissionIds + */ + public function __construct( + public string $clientId, + public string $accessStatus, + public array $roleIds = [], + public array $permissionIds = [], + ) { + } +} + diff --git a/src/DTO/TokenSet.php b/src/DTO/TokenSet.php new file mode 100644 index 0000000..6fc4a92 --- /dev/null +++ b/src/DTO/TokenSet.php @@ -0,0 +1,24 @@ + $scopes + * @param array $rawPayload + */ + public function __construct( + public string $accessToken, + public ?string $refreshToken, + public string $tokenType, + public int $expiresIn, + public ?\DateTimeImmutable $expiresAt = null, + public ?int $refreshExpiresIn = null, + public array $scopes = [], + public array $rawPayload = [], + ) { + } +} diff --git a/src/DTO/VerifiedWebhook.php b/src/DTO/VerifiedWebhook.php new file mode 100644 index 0000000..e7a4948 --- /dev/null +++ b/src/DTO/VerifiedWebhook.php @@ -0,0 +1,20 @@ + $normalizedHeaders + */ + public function __construct( + public string $rawBody, + public array $normalizedHeaders, + public ?string $signatureId = null, + public ?\DateTimeImmutable $verifiedAt = null, + ) { + } +} + diff --git a/src/DTO/WebhookEvent.php b/src/DTO/WebhookEvent.php new file mode 100644 index 0000000..16661ba --- /dev/null +++ b/src/DTO/WebhookEvent.php @@ -0,0 +1,26 @@ + $targetIdentifiers + * @param array $actorIdentifiers + * @param array $metadata + * @param array $rawPayload + */ + public function __construct( + public ?string $eventId, + public string $eventType, + public ?\DateTimeImmutable $occurredAt = null, + public array $targetIdentifiers = [], + public array $actorIdentifiers = [], + public array $metadata = [], + public array $rawPayload = [], + ) { + } +} + diff --git a/src/Exception/ConfigurationException.php b/src/Exception/ConfigurationException.php new file mode 100644 index 0000000..8c707fd --- /dev/null +++ b/src/Exception/ConfigurationException.php @@ -0,0 +1,10 @@ + $scopes + */ + public function build(string $state, string $pkceChallenge, ?string $returnTo = null, array $scopes = []): string + { + $query = [ + 'response_type' => 'code', + 'client_id' => $this->config->clientId(), + 'redirect_uri' => $this->config->redirectUri(), + 'state' => $state, + 'code_challenge' => $pkceChallenge, + 'code_challenge_method' => 'S256', + ]; + + if ($scopes !== []) { + $query['scope'] = implode(' ', $scopes); + } + + if ($returnTo !== null && $returnTo !== '') { + $query['return_to'] = $returnTo; + } + + return $this->config->authorizeUrl() . '?' . http_build_query($query, arg_separator: '&', encoding_type: PHP_QUERY_RFC3986); + } +} + diff --git a/src/OAuth/HttpTokenEndpoint.php b/src/OAuth/HttpTokenEndpoint.php new file mode 100644 index 0000000..6222650 --- /dev/null +++ b/src/OAuth/HttpTokenEndpoint.php @@ -0,0 +1,172 @@ + 'authorization_code', + 'client_id' => $this->config->clientId(), + 'client_secret' => $this->config->clientSecret(), + 'redirect_uri' => $this->config->redirectUri(), + 'code' => $code, + 'code_verifier' => $pkceVerifier, + ]; + + $data = $this->sendFormRequest($this->config->tokenUrl(), $payload, TokenExchangeException::class); + + return $this->mapTokenSet($data); + } + + public function refreshToken(string $refreshToken): TokenSet + { + $payload = [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->config->clientId(), + 'client_secret' => $this->config->clientSecret(), + 'refresh_token' => $refreshToken, + ]; + + $data = $this->sendFormRequest($this->config->refreshUrl(), $payload, TokenRefreshException::class); + + return $this->mapTokenSet($data); + } + + public function revokeToken(string $token, ?string $tokenTypeHint = null): void + { + $payload = [ + 'client_id' => $this->config->clientId(), + 'client_secret' => $this->config->clientSecret(), + 'token' => $token, + ]; + + if ($tokenTypeHint !== null && $tokenTypeHint !== '') { + $payload['token_type_hint'] = $tokenTypeHint; + } + + $this->sendFormRequest($this->config->revokeUrl(), $payload, TokenRevokeException::class, expectJson: false); + } + + /** + * @param class-string<\RuntimeException> $exceptionClass + * @return array + */ + private function sendFormRequest( + string $url, + array $payload, + string $exceptionClass, + bool $expectJson = true, + ): array { + $body = http_build_query($payload, arg_separator: '&', encoding_type: PHP_QUERY_RFC3986); + + $request = $this->requestFactory->createRequest('POST', $url) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withHeader('Accept', 'application/json'); + + if ($this->config->userAgent() !== null) { + $request = $request->withHeader('User-Agent', $this->config->userAgent()); + } + + $request = $request->withBody($this->streamFactory->createStream($body)); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $exception) { + $this->logger?->error('gnexus-auth token transport failure', [ + 'url' => $url, + 'message' => $exception->getMessage(), + ]); + + throw new TransportException('Request to gnexus-auth failed.', 0, $exception); + } + + if ($response->getStatusCode() >= 400) { + $payload = (string) $response->getBody(); + $message = $this->extractErrorMessage($payload) ?? 'gnexus-auth returned an error response.'; + + throw new $exceptionClass($message); + } + + if (! $expectJson) { + return []; + } + + $decoded = json_decode((string) $response->getBody(), true); + + if (! is_array($decoded)) { + throw new $exceptionClass('gnexus-auth returned malformed JSON.'); + } + + return $decoded; + } + + /** + * @param array $data + */ + private function mapTokenSet(array $data): TokenSet + { + $expiresIn = (int) ($data['expires_in'] ?? 0); + $refreshExpiresIn = isset($data['refresh_expires_in']) ? (int) $data['refresh_expires_in'] : null; + $scope = isset($data['scope']) && is_string($data['scope']) ? preg_split('/\s+/', trim($data['scope'])) : []; + $scopes = array_values(array_filter($scope ?: [], static fn ($item) => $item !== '')); + + $expiresAt = $expiresIn > 0 ? (new \DateTimeImmutable())->modify(sprintf('+%d seconds', $expiresIn)) : null; + + return new TokenSet( + accessToken: (string) ($data['access_token'] ?? ''), + refreshToken: isset($data['refresh_token']) ? (string) $data['refresh_token'] : null, + tokenType: (string) ($data['token_type'] ?? 'Bearer'), + expiresIn: $expiresIn, + expiresAt: $expiresAt, + refreshExpiresIn: $refreshExpiresIn, + scopes: $scopes, + rawPayload: $data, + ); + } + + private function extractErrorMessage(string $payload): ?string + { + $decoded = json_decode($payload, true); + + if (! is_array($decoded)) { + return null; + } + + if (isset($decoded['error_description']) && is_string($decoded['error_description'])) { + return $decoded['error_description']; + } + + if (isset($decoded['error']) && is_string($decoded['error'])) { + return $decoded['error']; + } + + return null; + } +} + diff --git a/src/OAuth/PkceGenerator.php b/src/OAuth/PkceGenerator.php new file mode 100644 index 0000000..0edb94f --- /dev/null +++ b/src/OAuth/PkceGenerator.php @@ -0,0 +1,29 @@ +requestFactory->createRequest('GET', $this->config->userInfoUrl()) + ->withHeader('Accept', 'application/json') + ->withHeader('Authorization', sprintf('Bearer %s', $accessToken)); + + if ($this->config->userAgent() !== null) { + $request = $request->withHeader('User-Agent', $this->config->userAgent()); + } + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $exception) { + $this->logger?->error('gnexus-auth runtime transport failure', [ + 'message' => $exception->getMessage(), + ]); + + throw new TransportException('Request to gnexus-auth runtime API failed.', 0, $exception); + } + + if ($response->getStatusCode() >= 400) { + throw new RuntimeApiException('gnexus-auth runtime API returned an error response.'); + } + + $payload = json_decode((string) $response->getBody(), true); + + if (! is_array($payload)) { + throw new RuntimeApiException('gnexus-auth runtime API returned malformed JSON.'); + } + + $clientAccessList = []; + $client = $payload['client'] ?? null; + + if (is_array($client) && isset($client['client_id']) && is_string($client['client_id'])) { + $clientAccessList[] = new ClientAccess( + clientId: $client['client_id'], + accessStatus: 'granted', + roleIds: $this->stringList($client['roles'] ?? []), + permissionIds: $this->stringList($client['permissions'] ?? []), + ); + } + + return new AuthenticatedUser( + userId: (string) ($payload['sub'] ?? $payload['id'] ?? ''), + email: (string) ($payload['email'] ?? ''), + emailVerified: (bool) ($payload['email_verified'] ?? false), + systemRole: isset($payload['system_role']) ? (string) $payload['system_role'] : null, + status: isset($payload['status']) ? (string) $payload['status'] : null, + profile: is_array($payload['profile'] ?? null) ? $payload['profile'] : [], + clientAccessList: $clientAccessList, + rawPayload: $payload, + ); + } + + /** + * @param mixed $value + * @return array + */ + private function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_map(static fn ($item) => (string) $item, $value)); + } +} + diff --git a/src/Support/InMemoryPkceStore.php b/src/Support/InMemoryPkceStore.php new file mode 100644 index 0000000..e8a1799 --- /dev/null +++ b/src/Support/InMemoryPkceStore.php @@ -0,0 +1,44 @@ + + */ + private array $items = []; + + public function put(string $state, string $verifier, \DateTimeImmutable $expiresAt): void + { + $this->items[$state] = [ + 'verifier' => $verifier, + 'expires_at' => $expiresAt, + ]; + } + + public function get(string $state): ?string + { + if (! isset($this->items[$state])) { + return null; + } + + if ($this->items[$state]['expires_at'] < new \DateTimeImmutable()) { + unset($this->items[$state]); + + return null; + } + + return $this->items[$state]['verifier']; + } + + public function forget(string $state): void + { + unset($this->items[$state]); + } +} + diff --git a/src/Support/InMemoryStateStore.php b/src/Support/InMemoryStateStore.php new file mode 100644 index 0000000..e3a4393 --- /dev/null +++ b/src/Support/InMemoryStateStore.php @@ -0,0 +1,53 @@ +}> + */ + private array $items = []; + + public function put(string $state, \DateTimeImmutable $expiresAt, array $context = []): void + { + $this->items[$state] = [ + 'expires_at' => $expiresAt, + 'context' => $context, + ]; + } + + public function has(string $state): bool + { + if (! isset($this->items[$state])) { + return false; + } + + if ($this->items[$state]['expires_at'] < new \DateTimeImmutable()) { + unset($this->items[$state]); + + return false; + } + + return true; + } + + public function getContext(string $state): array + { + if (! $this->has($state)) { + return []; + } + + return $this->items[$state]['context']; + } + + public function forget(string $state): void + { + unset($this->items[$state]); + } +} + diff --git a/src/Support/InMemoryTokenStore.php b/src/Support/InMemoryTokenStore.php new file mode 100644 index 0000000..458c757 --- /dev/null +++ b/src/Support/InMemoryTokenStore.php @@ -0,0 +1,32 @@ + + */ + private array $items = []; + + public function put(string $key, TokenSet $tokenSet): void + { + $this->items[$key] = $tokenSet; + } + + public function get(string $key): ?TokenSet + { + return $this->items[$key] ?? null; + } + + public function forget(string $key): void + { + unset($this->items[$key]); + } +} + diff --git a/src/Support/SystemClock.php b/src/Support/SystemClock.php new file mode 100644 index 0000000..41a5f82 --- /dev/null +++ b/src/Support/SystemClock.php @@ -0,0 +1,16 @@ +clock = $clock ?? new SystemClock(); + } + + public function verify(string $rawBody, array $headers, string $secret): VerifiedWebhook + { + $normalized = $this->normalizeHeaders($headers); + + foreach (['x-gnexus-event-id', 'x-gnexus-event-type', 'x-gnexus-event-timestamp', 'x-gnexus-signature'] as $requiredHeader) { + if (! isset($normalized[$requiredHeader]) || $normalized[$requiredHeader] === '') { + throw new WebhookVerificationException(sprintf('Missing webhook header: %s.', $requiredHeader)); + } + } + + $signature = $this->parseSignatureHeader($normalized['x-gnexus-signature']); + $timestamp = $signature['timestamp']; + $expectedSignature = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); + + if (! hash_equals($expectedSignature, $signature['hash'])) { + throw new WebhookVerificationException('Invalid webhook signature.'); + } + + $tolerance = $this->config->webhookToleranceSeconds(); + + if ($tolerance > 0 && abs($this->clock->now()->getTimestamp() - $timestamp) > $tolerance) { + throw new WebhookVerificationException('Webhook timestamp is outside the allowed tolerance window.'); + } + + return new VerifiedWebhook( + rawBody: $rawBody, + normalizedHeaders: $normalized, + signatureId: $normalized['x-gnexus-event-id'], + verifiedAt: $this->clock->now(), + ); + } + + /** + * @param array $headers + * @return array + */ + private function normalizeHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $value) { + $normalizedName = strtolower((string) $name); + $normalizedName = str_replace('_', '-', $normalizedName); + + if (is_array($value)) { + $value = $value[0] ?? ''; + } + + $normalized[$normalizedName] = trim((string) $value); + } + + return $normalized; + } + + /** + * @return array{timestamp:int, hash:string} + */ + private function parseSignatureHeader(string $header): array + { + $parts = []; + + foreach (explode(',', $header) as $chunk) { + [$key, $value] = array_pad(explode('=', trim($chunk), 2), 2, null); + + if ($key !== null && $value !== null) { + $parts[$key] = $value; + } + } + + if (! isset($parts['t'], $parts['v1'])) { + throw new WebhookVerificationException('Malformed webhook signature header.'); + } + + if (! ctype_digit($parts['t'])) { + throw new WebhookVerificationException('Webhook timestamp must be numeric.'); + } + + return [ + 'timestamp' => (int) $parts['t'], + 'hash' => strtolower($parts['v1']), + ]; + } +} + diff --git a/src/Webhook/JsonWebhookParser.php b/src/Webhook/JsonWebhookParser.php new file mode 100644 index 0000000..989b02f --- /dev/null +++ b/src/Webhook/JsonWebhookParser.php @@ -0,0 +1,46 @@ +buildAuthorizationRequest('/dashboard', ['openid', 'profile']); + + self::assertNotSame('', $request->state); + self::assertNotSame('', $request->pkceVerifier); + self::assertNotSame('', $request->pkceChallenge); + self::assertTrue($stateStore->has($request->state)); + self::assertSame($request->pkceVerifier, $pkceStore->get($request->state)); + self::assertStringContainsString('response_type=code', $request->authorizationUrl); + self::assertStringContainsString('client_id=billing', $request->authorizationUrl); + self::assertStringContainsString('scope=openid%20profile', $request->authorizationUrl); + self::assertStringContainsString('return_to=%2Fdashboard', $request->authorizationUrl); + } +} + diff --git a/tests/Unit/Config/GAuthConfigTest.php b/tests/Unit/Config/GAuthConfigTest.php new file mode 100644 index 0000000..ba778c5 --- /dev/null +++ b/tests/Unit/Config/GAuthConfigTest.php @@ -0,0 +1,41 @@ +authorizeUrl()); + self::assertSame('https://auth.example.test/oauth/token', $config->tokenUrl()); + self::assertSame('https://auth.example.test/oauth/refresh', $config->refreshUrl()); + self::assertSame('https://auth.example.test/oauth/revoke', $config->revokeUrl()); + self::assertSame('https://auth.example.test/oauth/userinfo', $config->userInfoUrl()); + } + + public function testRejectsInvalidBaseUrl(): void + { + $this->expectException(ConfigurationException::class); + + new GAuthConfig( + baseUrl: 'not-a-url', + clientId: 'billing', + clientSecret: 'secret', + redirectUri: 'https://billing.example.test/callback', + ); + } +} + diff --git a/tests/Unit/OAuth/AuthorizationUrlBuilderTest.php b/tests/Unit/OAuth/AuthorizationUrlBuilderTest.php new file mode 100644 index 0000000..5dbaf3a --- /dev/null +++ b/tests/Unit/OAuth/AuthorizationUrlBuilderTest.php @@ -0,0 +1,38 @@ +build( + state: 'state123', + pkceChallenge: 'challenge123', + returnTo: '/back', + scopes: ['openid', 'profile'], + ); + + self::assertStringStartsWith('https://auth.example.test/oauth/authorize?', $url); + self::assertStringContainsString('client_id=billing', $url); + self::assertStringContainsString('redirect_uri=https%3A%2F%2Fbilling.example.test%2Fcallback', $url); + self::assertStringContainsString('state=state123', $url); + self::assertStringContainsString('code_challenge=challenge123', $url); + self::assertStringContainsString('scope=openid%20profile', $url); + self::assertStringContainsString('return_to=%2Fback', $url); + } +} + diff --git a/tests/Unit/Webhook/HmacWebhookVerifierTest.php b/tests/Unit/Webhook/HmacWebhookVerifierTest.php new file mode 100644 index 0000000..85233b1 --- /dev/null +++ b/tests/Unit/Webhook/HmacWebhookVerifierTest.php @@ -0,0 +1,85 @@ +verify($body, [ + 'X-GNexus-Event-Id' => 'evt_1', + 'X-GNexus-Event-Type' => 'webhook.test', + 'X-GNexus-Event-Timestamp' => $timestamp, + 'X-GNexus-Signature' => 't=' . $timestamp . ',v1=' . $signature, + ], 'whsec_test'); + + self::assertSame('evt_1', $verified->signatureId); + } + + public function testRejectsTimestampOutsideTolerance(): void + { + $clock = new class implements ClockInterface + { + public function now(): \DateTimeImmutable + { + return new \DateTimeImmutable('@1713901000'); + } + }; + + $verifier = new HmacWebhookVerifier( + new GAuthConfig( + baseUrl: 'https://auth.example.test', + clientId: 'billing', + clientSecret: 'secret', + redirectUri: 'https://billing.example.test/callback', + webhookToleranceSeconds: 10, + ), + $clock, + ); + + $this->expectException(WebhookVerificationException::class); + + $body = '{"id":"evt_1","type":"webhook.test"}'; + $timestamp = '1713900000'; + $signature = hash_hmac('sha256', $timestamp . '.' . $body, 'whsec_test'); + + $verifier->verify($body, [ + 'X-GNexus-Event-Id' => 'evt_1', + 'X-GNexus-Event-Type' => 'webhook.test', + 'X-GNexus-Event-Timestamp' => $timestamp, + 'X-GNexus-Signature' => 't=' . $timestamp . ',v1=' . $signature, + ], 'whsec_test'); + } +} + diff --git a/tests/Unit/Webhook/JsonWebhookParserTest.php b/tests/Unit/Webhook/JsonWebhookParserTest.php new file mode 100644 index 0000000..b951243 --- /dev/null +++ b/tests/Unit/Webhook/JsonWebhookParserTest.php @@ -0,0 +1,33 @@ +parse(json_encode([ + 'id' => 'evt_1', + 'type' => 'user.profile_updated', + 'occurred_at' => '2026-04-24T12:00:00Z', + 'actor' => ['user_id' => '1'], + 'target' => ['user_id' => '2'], + 'data' => ['fields' => ['display_name']], + ], JSON_THROW_ON_ERROR)); + + self::assertSame('evt_1', $event->eventId); + self::assertSame('user.profile_updated', $event->eventType); + self::assertSame(['user_id' => '1'], $event->actorIdentifiers); + self::assertSame(['user_id' => '2'], $event->targetIdentifiers); + self::assertSame(['fields' => ['display_name']], $event->metadata); + self::assertSame('2026-04-24T12:00:00+00:00', $event->occurredAt?->format(\DateTimeInterface::ATOM)); + } +} + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..2ee7b18 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,28 @@ +