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 @@
+