Skip to content

Commit

Permalink
Improve ActingAsKeycloakUser trait (#114)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
elnurvl authored May 25, 2024
1 parent 347a969 commit 25ca4d2
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 19 deletions.
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"prefer-stable": true,
"require": {
"firebase/php-jwt": "^6.3",
"php": "^8.0"
"php": "^8.0",
"ext-openssl": "*"
},
"autoload": {
"psr-4": {
Expand Down
38 changes: 27 additions & 11 deletions src/ActingAsKeycloakUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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');
}
}
66 changes: 65 additions & 1 deletion tests/AuthenticateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace KeycloakGuard\Tests;

use Firebase\JWT\JWT;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Hashing\BcryptHasher;
use Illuminate\Support\Facades\Auth;
Expand All @@ -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;

Expand Down Expand Up @@ -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()
Expand All @@ -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'],
];
}
}
10 changes: 6 additions & 4 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down

0 comments on commit 25ca4d2

Please sign in to comment.