From 25ca4d207ddce5e7623b48a0325ea1f044f9f901 Mon Sep 17 00:00:00 2001 From: Elnur Hajiyev Date: Sat, 25 May 2024 23:12:24 +0400 Subject: [PATCH] Improve ActingAsKeycloakUser trait (#114) * Improve token payload in tests * Add unit tests and update the README * Specify an issuer in test token payloads * Specify authorized party and audience in test token payloads * Allow defining a class-level payload * Don't disable db loading if a user is given in a payload * Update the README * Update the README * Update a variable name --- README.md | 50 +++++++++++++++++++++++++-- composer.json | 3 +- src/ActingAsKeycloakUser.php | 38 +++++++++++++++------ tests/AuthenticateTest.php | 66 +++++++++++++++++++++++++++++++++++- tests/TestCase.php | 10 +++--- 5 files changed, 148 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b4a6d9c..34ce398 100644 --- a/README.md +++ b/README.md @@ -329,9 +329,9 @@ Auth::hasAnyScope(['scope-a', 'scope-d']) // true Auth::hasAnyScope(['scope-f', 'scope-k']) // false ``` -# Acting as a Keycloak user in tests +## Acting as a Keycloak user in tests -As an equivelant feature like `$this->actingAs($user)` in Laravel, with this package you can use `KeycloakGuard\ActingAsKeycloakUser` trait in your test class and then use `actingAsKeycloakUser()` method to act as a user and somehow skip the Keycloak auth: +As an equivalent feature like `$this->actingAs($user)` in Laravel, with this package you can use `KeycloakGuard\ActingAsKeycloakUser` trait in your test class and then use `actingAsKeycloakUser()` method to act as a user and somehow skip the Keycloak auth: ```php use KeycloakGuard\ActingAsKeycloakUser; @@ -346,6 +346,52 @@ public test_a_protected_route() If you are not using `keycloak.load_user_from_database` option, set `keycloak.preferred_username` with a valid `preferred_username` for tests. +You can also specify exact expectations for the token payload by passing the payload array in the second argument: + +```php +use KeycloakGuard\ActingAsKeycloakUser; + +public test_a_protected_route() +{ + $this->actingAsKeycloakUser($user, [ + 'aud' => 'account', + 'exp' => 1715926026, + 'iss' => 'https://localhost:8443/realms/master' + ])->getJson('/api/somewhere') + ->assertOk(); +} +``` +`$user` argument receives a string identifier or +an Eloquent model, identifier of which is expected to be the property referred in **user_provider_credential** config. +Whatever you pass in the payload will override default claims, +which includes `aud`, `iat`, `exp`, `iss`, `azp`, `resource_access` and either `sub` or `preferred_username`, +depending on **token_principal_attribute** config. + +Alternatively, payload can be provided in a class property, so it can be reused across multiple tests: + +```php +use KeycloakGuard\ActingAsKeycloakUser; + +protected $tokenPayload = [ + 'aud' => 'account', + 'exp' => 1715926026, + 'iss' => 'https://localhost:8443/realms/master' +]; + +public test_a_protected_route() +{ + $payload = [ + 'exp' => 1715914352 + ]; + $this->actingAsKeycloakUser($user, $payload) + ->getJson('/api/somewhere') + ->assertOk(); +} +``` + +Priority is given to the claims in passed as an argument, so they will override ones in the class property. +`$user` argument has the highest priority over the claim referred in **token_principal_attribute** config. + # Contribute You can run this project on VSCODE with Remote Container. Make sure you will use internal VSCODE terminal (inside running container). diff --git a/composer.json b/composer.json index f855805..f963dad 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "prefer-stable": true, "require": { "firebase/php-jwt": "^6.3", - "php": "^8.0" + "php": "^8.0", + "ext-openssl": "*" }, "autoload": { "psr-4": { diff --git a/src/ActingAsKeycloakUser.php b/src/ActingAsKeycloakUser.php index 3664073..2b9f257 100644 --- a/src/ActingAsKeycloakUser.php +++ b/src/ActingAsKeycloakUser.php @@ -7,20 +7,23 @@ trait ActingAsKeycloakUser { - public function actingAsKeycloakUser($user = null) + protected array $jwtPayload = []; + + public function actingAsKeycloakUser($user = null, $payload = []): self { - if (!$user) { + $principal = Config::get('keycloak.token_principal_attribute'); + if (!$user && !isset($payload[$principal]) && !isset($this->jwtPayload[$principal])) { Config::set('keycloak.load_user_from_database', false); } - $token = $this->generateKeycloakToken($user); + $token = $this->generateKeycloakToken($user, $payload); $this->withHeader('Authorization', 'Bearer '.$token); return $this; } - public function generateKeycloakToken($user = null) + public function generateKeycloakToken($user = null, $payload = []): string { $privateKey = openssl_pkey_new([ 'digest_alg' => 'sha256', @@ -34,13 +37,26 @@ public function generateKeycloakToken($user = null) Config::set('keycloak.realm_public_key', $publicKey); - $payload = [ - 'preferred_username' => $user->username ?? config('keycloak.preferred_username'), - 'resource_access' => [config('keycloak.allowed_resources') => []] - ]; - - $token = JWT::encode($payload, $privateKey, 'RS256'); + $iat = time(); + $exp = time() + 300; + $resourceAccess = [config('keycloak.allowed_resources') => []]; + $principal = Config::get('keycloak.token_principal_attribute'); + $credential = Config::get('keycloak.user_provider_credential'); + + $payload = array_merge([ + 'iss' => 'https://keycloak.server/realms/laravel', + 'azp' => 'client-id', + 'aud' => 'phpunit', + 'iat' => $iat, + 'exp' => $exp, + $principal => config('keycloak.preferred_username'), + 'resource_access' => $resourceAccess, + ], $this->jwtPayload, $payload); + + if ($user) { + $payload[$principal] = is_string($user) ? $user : $user->$credential; + } - return $token; + return JWT::encode($payload, $privateKey, 'RS256'); } } diff --git a/tests/AuthenticateTest.php b/tests/AuthenticateTest.php index 609c8b1..2c871f0 100644 --- a/tests/AuthenticateTest.php +++ b/tests/AuthenticateTest.php @@ -2,6 +2,7 @@ namespace KeycloakGuard\Tests; +use Firebase\JWT\JWT; use Illuminate\Auth\AuthenticationException; use Illuminate\Hashing\BcryptHasher; use Illuminate\Support\Facades\Auth; @@ -11,6 +12,7 @@ use KeycloakGuard\Exceptions\UserNotFoundException; use KeycloakGuard\KeycloakGuard; use KeycloakGuard\Tests\Extensions\CustomUserProvider; +use KeycloakGuard\Tests\Factories\UserFactory; use KeycloakGuard\Tests\Models\User; use KeycloakGuard\Token; @@ -410,11 +412,65 @@ public function test_authentication_prefers_bearer_token_over_with_custom_input_ $this->json('POST', '/foo/secret', ['api_token' => $this->token]); } - public function test_with_keycloak_token_trait() + public function test_acting_as_keycloak_user_trait() { $this->actingAsKeycloakUser($this->user)->json('GET', '/foo/secret'); $this->assertEquals($this->user->username, Auth::user()->username); + $token = Token::decode(request()->bearerToken(), config('keycloak.realm_public_key'), config('keycloak.leeway'), config('keycloak.token_encryption_algorithm')); + $this->assertNotNull($token->iat); + $this->assertNotNull($token->exp); + $this->assertNotNull($token->iss); + $this->assertNotNull($token->azp); + $this->assertNotNull($token->aud); + } + + public function test_acting_as_keycloak_user_trait_with_username() + { + $this->actingAsKeycloakUser($this->user->username)->json('GET', '/foo/secret'); + + $this->assertEquals($this->user->username, Auth::user()->username); + $token = Token::decode(request()->bearerToken(), config('keycloak.realm_public_key'), config('keycloak.leeway'), config('keycloak.token_encryption_algorithm')); + $this->assertNotNull($token->iat); + $this->assertNotNull($token->exp); + } + + /** + * @dataProvider scopeProvider + * + * @return void + */ + public function test_acting_as_keycloak_user_trait_with_custom_payload(string $scope) + { + UserFactory::new()->create([ + 'username' => 'test_username', + ]); + $payload = [ + 'sub' => 'test_sub', + 'aud' => 'test_aud', + 'preferred_username' => 'test_username', + 'iat' => 12345, + 'exp' => 9999999999999, + ]; + + $arg = []; + + if ($scope === 'class') { + $this->jwtPayload = $payload; + } else { + $this->jwtPayload['sub'] = 'should_be_overwritten'; + $arg = $payload; + } + + $this->actingAsKeycloakUser(payload: $arg)->json('GET', '/foo/secret'); + + $this->assertEquals('test_username', Auth::user()->username); + $token = Token::decode(request()->bearerToken(), config('keycloak.realm_public_key'), config('keycloak.leeway'), config('keycloak.token_encryption_algorithm')); + $this->assertEquals(12345, $token->iat); + $this->assertEquals(9999999999999, $token->exp); + $this->assertEquals('test_sub', $token->sub); + $this->assertEquals('test_aud', $token->aud); + $this->assertTrue(config('keycloak.load_user_from_database')); } public function test_acting_as_keycloak_user_trait_without_user() @@ -441,4 +497,12 @@ public function test_it_decodes_token_with_the_configured_encryption_algorithm() $this->withKeycloakToken()->json('GET', '/foo/secret'); $this->assertEquals($this->user->username, Auth::user()->username); } + + public function scopeProvider(): array + { + return [ + ['local'], + ['class'], + ]; + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 6429b3e..a97f878 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -18,9 +18,11 @@ class TestCase extends Orchestra { public OpenSSLAsymmetricKey $privateKey; public string $publicKey; - public array $payload; + public array $defaultPayload; public string $token; + protected User $user; + protected function setUp(): void { // Prepare credentials @@ -53,12 +55,12 @@ protected function prepareCredentials(string $encryptionAlgorithm = 'RS256', ?ar $this->publicKey = openssl_pkey_get_details($this->privateKey)['key']; - $this->payload = [ + $this->defaultPayload = [ 'preferred_username' => 'johndoe', 'resource_access' => ['myapp-backend' => []] ]; - $this->token = JWT::encode($this->payload, $this->privateKey, $encryptionAlgorithm); + $this->token = JWT::encode($this->defaultPayload, $this->privateKey, $encryptionAlgorithm); } // Default configs to make it running @@ -102,7 +104,7 @@ protected function getPackageProviders($app) // Build a different token with custom payload protected function buildCustomToken(array $payload, string $encryptionAlgorithm = 'RS256') { - $payload = array_replace($this->payload, $payload); + $payload = array_replace($this->defaultPayload, $payload); $this->token = JWT::encode($payload, $this->privateKey, $encryptionAlgorithm); }