From 8bd35f67f71ae29bd24d18ad7ffaeab1a23aca7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:29:25 +0000 Subject: [PATCH 1/3] Initial plan From bcbceea292ba9bb58c2c43b31f3118e83d3df72e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:43:06 +0000 Subject: [PATCH 2/3] Implement authentication strategy pattern with token support Co-authored-by: mehmet-yoti <111424390+mehmet-yoti@users.noreply.github.com> --- src/Http/Auth/AuthStrategyInterface.php | 35 +++++++++++ src/Http/Auth/SignedRequestAuthStrategy.php | 50 +++++++++++++++ src/Http/Auth/TokenAuthStrategy.php | 41 ++++++++++++ src/Http/RequestBuilder.php | 58 +++++++++++++---- src/Util/Config.php | 13 ++++ .../Auth/SignedRequestAuthStrategyTest.php | 63 +++++++++++++++++++ tests/Http/Auth/TokenAuthStrategyTest.php | 61 ++++++++++++++++++ tests/Http/RequestBuilderTest.php | 42 +++++++++++++ tests/Util/ConfigTest.php | 26 ++++++++ 9 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 src/Http/Auth/AuthStrategyInterface.php create mode 100644 src/Http/Auth/SignedRequestAuthStrategy.php create mode 100644 src/Http/Auth/TokenAuthStrategy.php create mode 100644 tests/Http/Auth/SignedRequestAuthStrategyTest.php create mode 100644 tests/Http/Auth/TokenAuthStrategyTest.php diff --git a/src/Http/Auth/AuthStrategyInterface.php b/src/Http/Auth/AuthStrategyInterface.php new file mode 100644 index 00000000..fb5e754d --- /dev/null +++ b/src/Http/Auth/AuthStrategyInterface.php @@ -0,0 +1,35 @@ + $headers + * The request headers to modify. + * @param string $endpoint + * The request endpoint with query parameters. + * @param string $httpMethod + * The HTTP method (GET, POST, etc.). + * @param \Yoti\Http\Payload|null $payload + * The request payload, if any. + * + * @return array + * The modified headers with authentication applied. + */ + public function applyAuth( + array $headers, + string $endpoint, + string $httpMethod, + ?Payload $payload = null + ): array; +} diff --git a/src/Http/Auth/SignedRequestAuthStrategy.php b/src/Http/Auth/SignedRequestAuthStrategy.php new file mode 100644 index 00000000..fef2dadf --- /dev/null +++ b/src/Http/Auth/SignedRequestAuthStrategy.php @@ -0,0 +1,50 @@ +pemFile = $pemFile; + } + + /** + * {@inheritdoc} + */ + public function applyAuth( + array $headers, + string $endpoint, + string $httpMethod, + ?Payload $payload = null + ): array { + $headers[self::YOTI_DIGEST_HEADER_KEY] = RequestSigner::sign( + $this->pemFile, + $endpoint, + $httpMethod, + $payload + ); + + return $headers; + } +} diff --git a/src/Http/Auth/TokenAuthStrategy.php b/src/Http/Auth/TokenAuthStrategy.php new file mode 100644 index 00000000..f05cdda2 --- /dev/null +++ b/src/Http/Auth/TokenAuthStrategy.php @@ -0,0 +1,41 @@ +token = $token; + } + + /** + * {@inheritdoc} + */ + public function applyAuth( + array $headers, + string $endpoint, + string $httpMethod, + ?Payload $payload = null + ): array { + $headers['Authorization'] = 'Bearer ' . $this->token; + + return $headers; + } +} diff --git a/src/Http/RequestBuilder.php b/src/Http/RequestBuilder.php index e2413181..79b3e5fd 100644 --- a/src/Http/RequestBuilder.php +++ b/src/Http/RequestBuilder.php @@ -8,6 +8,9 @@ use GuzzleHttp\Psr7\Utils; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\StreamInterface; +use Yoti\Http\Auth\AuthStrategyInterface; +use Yoti\Http\Auth\SignedRequestAuthStrategy; +use Yoti\Http\Auth\TokenAuthStrategy; use Yoti\Util\Config; use Yoti\Util\PemFile; @@ -32,6 +35,11 @@ class RequestBuilder */ private $pemFile; + /** + * @var \Yoti\Http\Auth\AuthStrategyInterface|null + */ + private $authStrategy; + /** * @var array */ @@ -115,6 +123,17 @@ public function withPemFile(PemFile $pemFile): self return $this; } + /** + * @param \Yoti\Http\Auth\AuthStrategyInterface $authStrategy + * + * @return RequestBuilder + */ + public function withAuthStrategy(AuthStrategyInterface $authStrategy): self + { + $this->authStrategy = $authStrategy; + return $this; + } + /** * @param string $filePath * @@ -350,28 +369,43 @@ public function build(): Request throw new \InvalidArgumentException('Base URL must be provided to ' . __CLASS__); } - if (!isset($this->pemFile)) { - throw new \InvalidArgumentException('Pem file must be provided to ' . __CLASS__); - } - $this->validateMethod(); - // Add nonce and timestamp to the URL. - $this - ->withQueryParam('nonce', self::generateNonce()) - ->withQueryParam('timestamp', (string)(round(microtime(true) * 1000))); + // Determine auth strategy if not explicitly set + if (!isset($this->authStrategy)) { + $authToken = $this->config->getAuthToken(); + if ($authToken !== null) { + // Use token-based authentication + $this->authStrategy = new TokenAuthStrategy($authToken); + } elseif (isset($this->pemFile)) { + // Use signed request authentication + $this->authStrategy = new SignedRequestAuthStrategy($this->pemFile); + } else { + throw new \InvalidArgumentException( + 'Either Pem file or auth token must be provided to ' . __CLASS__ + ); + } + } + + // Add nonce and timestamp to the URL for signed request auth + if ($this->authStrategy instanceof SignedRequestAuthStrategy) { + $this + ->withQueryParam('nonce', self::generateNonce()) + ->withQueryParam('timestamp', (string)(round(microtime(true) * 1000))); + } $endpointWithParams = $this->endpoint . '?' . http_build_query($this->queryParams); $payload = isset($this->multipartEntity) ? Payload::fromStream($this->multipartEntity->createStream()) : $this->payload; - $this->withHeader(self::YOTI_DIGEST_HEADER_KEY, RequestSigner::sign( - $this->pemFile, + // Apply authentication + $headers = $this->authStrategy->applyAuth( + $this->getHeaders(), $endpointWithParams, $this->method, $payload - )); + ); $url = $this->baseUrl . $endpointWithParams; @@ -379,7 +413,7 @@ public function build(): Request $message = new RequestMessage( $this->method, Utils::uriFor($url), - $this->getHeaders(), + $headers, $this->getBodyByTypeOfRequest() ); diff --git a/src/Util/Config.php b/src/Util/Config.php index aceacac5..43c4d44f 100644 --- a/src/Util/Config.php +++ b/src/Util/Config.php @@ -29,6 +29,9 @@ class Config /** Logger key */ public const LOGGER = 'logger'; + /** Authentication token key */ + public const AUTH_TOKEN = 'auth.token'; + public const YOTI_MULTIPART_BOUNDARY = 'X-Yoti-Multipart-Request-Boundary'; /** Type error message */ @@ -51,6 +54,7 @@ class Config * - Config::API_URL 'api.url' (string) * - Config::SDK_IDENTIFIER 'sdk.identifier' (string) * - Config::SDK_VERSION 'sdk.version' (string) + * - Config::AUTH_TOKEN 'auth.token' (string) - For token-based authentication * * Example of creating config: * @@ -95,6 +99,7 @@ private function validateKeys($options): void self::SDK_VERSION, self::HTTP_CLIENT, self::LOGGER, + self::AUTH_TOKEN, ] ); if (count($invalidKeys) > 0) { @@ -213,4 +218,12 @@ public function getLogger(): LoggerInterface } return $this->get(self::LOGGER); } + + /** + * @return string|null + */ + public function getAuthToken(): ?string + { + return $this->get(self::AUTH_TOKEN); + } } diff --git a/tests/Http/Auth/SignedRequestAuthStrategyTest.php b/tests/Http/Auth/SignedRequestAuthStrategyTest.php new file mode 100644 index 00000000..ae16f6d6 --- /dev/null +++ b/tests/Http/Auth/SignedRequestAuthStrategyTest.php @@ -0,0 +1,63 @@ + 'application/json', + ]; + + $endpoint = '/some-endpoint?nonce=abc123×tamp=1234567890'; + $httpMethod = 'POST'; + $payload = Payload::fromString('test payload'); + + $result = $strategy->applyAuth($headers, $endpoint, $httpMethod, $payload); + + $this->assertArrayHasKey('X-Yoti-Auth-Digest', $result); + $this->assertNotEmpty($result['X-Yoti-Auth-Digest']); + $this->assertEquals('application/json', $result['Content-Type']); + } + + /** + * @covers ::applyAuth + */ + public function testApplyAuthWithoutPayload() + { + $pemFile = PemFile::fromFilePath(TestData::PEM_FILE); + $strategy = new SignedRequestAuthStrategy($pemFile); + + $headers = [ + 'Accept' => 'application/json', + ]; + + $endpoint = '/some-endpoint?nonce=abc123×tamp=1234567890'; + $httpMethod = 'GET'; + + $result = $strategy->applyAuth($headers, $endpoint, $httpMethod, null); + + $this->assertArrayHasKey('X-Yoti-Auth-Digest', $result); + $this->assertNotEmpty($result['X-Yoti-Auth-Digest']); + $this->assertEquals('application/json', $result['Accept']); + } +} diff --git a/tests/Http/Auth/TokenAuthStrategyTest.php b/tests/Http/Auth/TokenAuthStrategyTest.php new file mode 100644 index 00000000..6a611ce3 --- /dev/null +++ b/tests/Http/Auth/TokenAuthStrategyTest.php @@ -0,0 +1,61 @@ + 'application/json', + ]; + + $endpoint = '/some-endpoint'; + $httpMethod = 'POST'; + $payload = Payload::fromString('test payload'); + + $result = $strategy->applyAuth($headers, $endpoint, $httpMethod, $payload); + + $this->assertArrayHasKey('Authorization', $result); + $this->assertEquals('Bearer test-auth-token-12345', $result['Authorization']); + $this->assertEquals('application/json', $result['Content-Type']); + } + + /** + * @covers ::applyAuth + */ + public function testApplyAuthWithoutPayload() + { + $token = 'another-token'; + $strategy = new TokenAuthStrategy($token); + + $headers = [ + 'Accept' => 'application/json', + ]; + + $endpoint = '/some-endpoint'; + $httpMethod = 'GET'; + + $result = $strategy->applyAuth($headers, $endpoint, $httpMethod, null); + + $this->assertArrayHasKey('Authorization', $result); + $this->assertEquals('Bearer another-token', $result['Authorization']); + $this->assertEquals('application/json', $result['Accept']); + } +} diff --git a/tests/Http/RequestBuilderTest.php b/tests/Http/RequestBuilderTest.php index 75558344..ed297e15 100644 --- a/tests/Http/RequestBuilderTest.php +++ b/tests/Http/RequestBuilderTest.php @@ -436,4 +436,46 @@ public function testWithQueryParam() $this->assertNotNull($queryParams['nonce']); $this->assertNotNull($queryParams['timestamp']); } + + /** + * @covers ::build + * @covers ::withAuthStrategy + */ + public function testWithTokenAuthStrategy() + { + $token = 'test-token-12345'; + $config = new Config([ + Config::AUTH_TOKEN => $token, + ]); + + $request = (new RequestBuilder($config)) + ->withBaseUrl(self::SOME_BASE_URL) + ->withEndpoint(self::SOME_ENDPOINT) + ->withGet() + ->build(); + + $message = $request->getMessage(); + $this->assertArrayHasKey('Authorization', $message->getHeaders()); + $this->assertEquals(['Bearer ' . $token], $message->getHeader('Authorization')); + + // Token auth should not add nonce/timestamp + $uri = (string) $message->getUri(); + $this->assertStringNotContainsString('nonce=', $uri); + $this->assertStringNotContainsString('timestamp=', $uri); + } + + /** + * @covers ::build + */ + public function testBuildWithoutPemFileOrTokenThrowsException() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Either Pem file or auth token must be provided'); + + (new RequestBuilder()) + ->withBaseUrl(self::SOME_BASE_URL) + ->withEndpoint(self::SOME_ENDPOINT) + ->withGet() + ->build(); + } } diff --git a/tests/Util/ConfigTest.php b/tests/Util/ConfigTest.php index cf7120ef..35900308 100644 --- a/tests/Util/ConfigTest.php +++ b/tests/Util/ConfigTest.php @@ -215,4 +215,30 @@ public function testNullValuesIgnored() $this->assertEquals(Constants::SDK_IDENTIFIER, $config->getSdkIdentifier()); } + + /** + * @covers ::getAuthToken + * @covers ::__construct + * @covers ::validateKeys + * @covers ::get + * @covers ::set + * @covers ::setStringValue + */ + public function testGetAuthToken() + { + $token = 'test-auth-token-12345'; + $config = new Config([ + Config::AUTH_TOKEN => $token, + ]); + + $this->assertEquals($token, $config->getAuthToken()); + } + + /** + * @covers ::getAuthToken + */ + public function testGetAuthTokenDefaultIsNull() + { + $this->assertNull((new Config())->getAuthToken()); + } } From 05d9e304d0c0232e80d61d69823100948d9ba180 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:44:31 +0000 Subject: [PATCH 3/3] Add comprehensive authentication documentation Co-authored-by: mehmet-yoti <111424390+mehmet-yoti@users.noreply.github.com> --- README.md | 7 +- docs/AUTHENTICATION.md | 226 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 docs/AUTHENTICATION.md diff --git a/README.md b/README.md index 371c4fba..2eb3fbb7 100755 --- a/README.md +++ b/README.md @@ -56,13 +56,18 @@ $ composer require yoti/yoti-php-sdk "^4.4.1" For each service you will need: * Your Client SDK ID, generated by [Yoti Hub](https://hub.yoti.com) when you create (and then publish) your app. -* Your .pem file. This is your own unique private key which your browser generates from the [Yoti Hub](https://hub.yoti.com) when you create an application. +* Authentication credentials - either: + * Your .pem file (traditional method) - Your unique private key generated from the [Yoti Hub](https://hub.yoti.com) when you create an application. + * An authentication token (Central Auth) - For token-based authentication. + +For more details about authentication options, see the [Authentication Guide](/docs/AUTHENTICATION.md). ## Products The Yoti SDK can be used for the following products, follow the links for more information about each: 1) [Yoti app integration](/docs/PROFILE.md) - Connect with already-verified customers. 1) [Yoti Doc Scan](/docs/DOCSCAN.md) - Identity verification embedded in your website or app. +1) [Authentication](/docs/AUTHENTICATION.md) - Guide to authentication methods and configuration. ## Support diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 00000000..d2e9de5f --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,226 @@ +# Authentication + +This document describes the authentication mechanisms supported by the Yoti PHP SDK. + +## Overview + +The Yoti PHP SDK supports two authentication methods: + +1. **Signed Request Authentication** (Traditional) - Uses PEM file-based request signing +2. **Token-Based Authentication** (Central Auth) - Uses authentication tokens with Bearer authorization + +## Signed Request Authentication (Traditional) + +This is the traditional authentication method that uses a PEM file to sign requests. + +### Setup + +```php +use Yoti\YotiClient; + +$yotiClient = new YotiClient( + 'YOUR_CLIENT_SDK_ID', + '/path/to/your-application.pem' +); +``` + +### How It Works + +- Each request is signed using your private key from the PEM file +- The signature is sent in the `X-Yoti-Auth-Digest` header +- A nonce and timestamp are included as query parameters for security + +## Token-Based Authentication (Central Auth) + +The new token-based authentication provides a simpler and more flexible authentication mechanism. + +### Setup + +To use token-based authentication, provide the `auth.token` configuration option: + +```php +use Yoti\YotiClient; +use Yoti\Util\Config; + +$yotiClient = new YotiClient( + 'YOUR_CLIENT_SDK_ID', + '', // Empty PEM string when using token auth + [ + Config::AUTH_TOKEN => 'YOUR_AUTH_TOKEN' + ] +); +``` + +### Using with RequestBuilder + +You can also use token authentication directly with the `RequestBuilder`: + +```php +use Yoti\Http\RequestBuilder; +use Yoti\Util\Config; + +$config = new Config([ + Config::AUTH_TOKEN => 'YOUR_AUTH_TOKEN' +]); + +$request = (new RequestBuilder($config)) + ->withBaseUrl('https://api.yoti.com') + ->withEndpoint('/some-endpoint') + ->withGet() + ->build(); +``` + +### How It Works + +- The authentication token is sent in the `Authorization` header as a Bearer token +- No request signing is required +- No nonce or timestamp query parameters are added + +## Advanced Usage + +### Explicitly Setting Auth Strategy + +For advanced use cases, you can explicitly set the authentication strategy: + +```php +use Yoti\Http\Auth\TokenAuthStrategy; +use Yoti\Http\RequestBuilder; + +$authStrategy = new TokenAuthStrategy('YOUR_AUTH_TOKEN'); + +$request = (new RequestBuilder()) + ->withBaseUrl('https://api.yoti.com') + ->withEndpoint('/some-endpoint') + ->withAuthStrategy($authStrategy) + ->withGet() + ->build(); +``` + +### Custom Authentication Strategies + +You can implement your own authentication strategy by implementing the `AuthStrategyInterface`: + +```php +use Yoti\Http\Auth\AuthStrategyInterface; +use Yoti\Http\Payload; + +class CustomAuthStrategy implements AuthStrategyInterface +{ + public function applyAuth( + array $headers, + string $endpoint, + string $httpMethod, + ?Payload $payload = null + ): array { + // Add your custom authentication logic here + $headers['X-Custom-Auth'] = 'custom-value'; + return $headers; + } +} +``` + +## Migration Guide + +### Migrating from Signed Request to Token-Based Authentication + +1. **Obtain an authentication token** from Yoti Central Auth system +2. **Update your YotiClient initialization**: + +Before: +```php +$yotiClient = new YotiClient( + 'YOUR_CLIENT_SDK_ID', + '/path/to/your-application.pem' +); +``` + +After: +```php +use Yoti\Util\Config; + +$yotiClient = new YotiClient( + 'YOUR_CLIENT_SDK_ID', + '', // Empty PEM string + [ + Config::AUTH_TOKEN => 'YOUR_AUTH_TOKEN' + ] +); +``` + +3. **Update environment configuration** if you're using environment variables for configuration + +### Backward Compatibility + +The SDK maintains full backward compatibility with the signed request authentication method. Existing applications using PEM files will continue to work without any changes. + +## Best Practices + +1. **Token Security**: Store authentication tokens securely, similar to how you store PEM files +2. **Token Rotation**: Implement token rotation policies according to your security requirements +3. **Environment Variables**: Use environment variables to manage tokens in different environments (development, staging, production) +4. **Error Handling**: Implement proper error handling for authentication failures + +## Configuration Options + +The following configuration options are available: + +| Option | Type | Description | +|--------|------|-------------| +| `Config::AUTH_TOKEN` | `string` | Authentication token for token-based authentication | +| `Config::API_URL` | `string` | Base API URL (optional) | +| `Config::SDK_IDENTIFIER` | `string` | Custom SDK identifier (optional) | +| `Config::SDK_VERSION` | `string` | Custom SDK version (optional) | +| `Config::HTTP_CLIENT` | `ClientInterface` | Custom HTTP client (optional) | +| `Config::LOGGER` | `LoggerInterface` | Custom logger (optional) | + +## Example: Using Both Authentication Methods + +You can configure different services to use different authentication methods: + +```php +use Yoti\Http\RequestBuilder; +use Yoti\Http\Auth\SignedRequestAuthStrategy; +use Yoti\Http\Auth\TokenAuthStrategy; +use Yoti\Util\PemFile; + +// Service using signed request authentication +$pemFile = PemFile::fromFilePath('/path/to/your-application.pem'); +$signedRequestAuth = new SignedRequestAuthStrategy($pemFile); + +$request1 = (new RequestBuilder()) + ->withBaseUrl('https://api.yoti.com') + ->withEndpoint('/legacy-endpoint') + ->withAuthStrategy($signedRequestAuth) + ->withGet() + ->build(); + +// Service using token authentication +$tokenAuth = new TokenAuthStrategy('YOUR_AUTH_TOKEN'); + +$request2 = (new RequestBuilder()) + ->withBaseUrl('https://api.yoti.com') + ->withEndpoint('/new-endpoint') + ->withAuthStrategy($tokenAuth) + ->withGet() + ->build(); +``` + +## Troubleshooting + +### Common Issues + +**Invalid Token Error** +- Verify your token is correct and not expired +- Check that the token is properly formatted in the configuration + +**Authentication Method Not Working** +- Ensure you're using the correct authentication method for the endpoint you're calling +- Verify your SDK configuration is correct + +**Missing Authorization Header** +- Confirm that `Config::AUTH_TOKEN` is set when using token-based authentication +- Check that the RequestBuilder is using the correct Config instance + +## Support + +For questions or issues related to authentication, please contact Yoti support at https://support.yoti.com