generated from thegrind/laravel-dockerized
311 lines
11 KiB
PHP
311 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
use Lcobucci\JWT\Configuration;
|
|
use Lcobucci\JWT\Signer\Key\InMemory;
|
|
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
|
|
|
beforeEach(function () {
|
|
$this->user = User::factory()->create([
|
|
'name' => 'JWT Test User',
|
|
'email' => 'jwt@example.com',
|
|
]);
|
|
|
|
$this->application = Application::factory()->create([
|
|
'name' => 'JWT Test App',
|
|
'client_id' => 'jwt-test-client',
|
|
'client_secret' => 'jwt-test-secret',
|
|
'redirect_uri' => 'https://jwt.example.com/callback',
|
|
]);
|
|
|
|
// Create test keys
|
|
$this->createJWTTestKeys();
|
|
});
|
|
|
|
afterEach(function () {
|
|
$this->cleanupJWTTestKeys();
|
|
});
|
|
|
|
function createJWTTestKeys()
|
|
{
|
|
$keyDir = storage_path('oauth');
|
|
if (!file_exists($keyDir)) {
|
|
mkdir($keyDir, 0755, true);
|
|
}
|
|
|
|
$config = [
|
|
'digest_alg' => 'sha256',
|
|
'private_key_bits' => 2048,
|
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
];
|
|
|
|
$privateKey = openssl_pkey_new($config);
|
|
openssl_pkey_export($privateKey, $privateKeyPem);
|
|
file_put_contents(storage_path('oauth/private.pem'), $privateKeyPem);
|
|
|
|
$publicKey = openssl_pkey_get_details($privateKey);
|
|
file_put_contents(storage_path('oauth/public.pem'), $publicKey['key']);
|
|
}
|
|
|
|
function cleanupJWTTestKeys()
|
|
{
|
|
$files = [
|
|
storage_path('oauth/private.pem'),
|
|
storage_path('oauth/public.pem')
|
|
];
|
|
|
|
foreach ($files as $file) {
|
|
if (file_exists($file)) {
|
|
unlink($file);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('JWT ID Token Generation and Validation', function () {
|
|
it('generates valid JWT ID token', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
// Set up authorization flow
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid email profile',
|
|
'nonce' => 'test-nonce-123',
|
|
], now()->addMinutes(5));
|
|
|
|
// Exchange code for tokens
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'client_id' => $this->application->client_id,
|
|
'client_secret' => $this->application->client_secret,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json();
|
|
|
|
// Validate ID token structure and basic properties
|
|
$idToken = $data['id_token'];
|
|
expect($idToken)->toBeString();
|
|
expect(substr_count($idToken, '.'))->toBe(2); // JWT has 3 parts separated by dots
|
|
|
|
// Decode JWT payload for basic validation (without signature verification for simplicity)
|
|
$parts = explode('.', $idToken);
|
|
$payload = json_decode(base64_decode(str_pad(strtr($parts[1], '-_', '+/'), strlen($parts[1]) % 4, '=', STR_PAD_RIGHT)), true);
|
|
|
|
// Validate basic claims structure
|
|
expect($payload)->toHaveKeys(['iss', 'aud', 'sub', 'email', 'nonce', 'iat', 'exp']);
|
|
expect($payload['iss'])->toBe(config('app.url'));
|
|
expect($payload['aud'])->toBe($this->application->client_id);
|
|
expect($payload['sub'])->toBe((string) $this->user->id);
|
|
expect($payload['email'])->toBe($this->user->email);
|
|
expect($payload['nonce'])->toBe('test-nonce-123');
|
|
|
|
// Validate timing
|
|
expect($payload['iat'])->toBeLessThanOrEqual(time());
|
|
expect($payload['exp'])->toBeGreaterThan(time());
|
|
});
|
|
|
|
it('generates JWT without nonce when not provided', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid email profile',
|
|
// No nonce provided
|
|
], now()->addMinutes(5));
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'client_id' => $this->application->client_id,
|
|
'client_secret' => $this->application->client_secret,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json();
|
|
|
|
// Parse JWT payload for basic validation
|
|
$parts = explode('.', $data['id_token']);
|
|
$payload = json_decode(base64_decode(str_pad(strtr($parts[1], '-_', '+/'), strlen($parts[1]) % 4, '=', STR_PAD_RIGHT)), true);
|
|
|
|
// Verify nonce claim is not present
|
|
expect($payload)->not->toHaveKey('nonce');
|
|
});
|
|
|
|
it('JWT expires in 5 minutes', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid email profile',
|
|
], now()->addMinutes(5));
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'client_id' => $this->application->client_id,
|
|
'client_secret' => $this->application->client_secret,
|
|
]);
|
|
|
|
$data = $response->json();
|
|
|
|
// Parse JWT payload
|
|
$parts = explode('.', $data['id_token']);
|
|
$payload = json_decode(base64_decode(str_pad(strtr($parts[1], '-_', '+/'), strlen($parts[1]) % 4, '=', STR_PAD_RIGHT)), true);
|
|
|
|
// Should expire in 5 minutes (300 seconds)
|
|
expect($payload['exp'] - $payload['iat'])->toBe(300);
|
|
});
|
|
});
|
|
|
|
describe('PKCE Code Challenge Validation', function () {
|
|
it('validates S256 code challenge correctly', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$codeVerifier = Str::random(128);
|
|
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
|
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid',
|
|
'code_challenge' => $codeChallenge,
|
|
'code_challenge_method' => 'S256',
|
|
], now()->addMinutes(5));
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'code_verifier' => $codeVerifier,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
});
|
|
|
|
it('validates plain code challenge correctly', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$codeVerifier = 'plain-text-verifier';
|
|
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid',
|
|
'code_challenge' => $codeVerifier,
|
|
'code_challenge_method' => 'plain',
|
|
], now()->addMinutes(5));
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'code_verifier' => $codeVerifier,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
});
|
|
|
|
it('rejects invalid code challenge method', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid',
|
|
'code_challenge' => 'some-challenge',
|
|
'code_challenge_method' => 'invalid-method',
|
|
], now()->addMinutes(5));
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'code_verifier' => 'some-verifier',
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
});
|
|
});
|
|
|
|
describe('Access Token Management', function () {
|
|
it('creates authentication token record with correct metadata', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid email profile',
|
|
], now()->addMinutes(5));
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'client_id' => $this->application->client_id,
|
|
'client_secret' => $this->application->client_secret,
|
|
], [
|
|
'User-Agent' => 'Test Browser/1.0',
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json();
|
|
|
|
// Verify token record was created with metadata
|
|
$tokenRecord = $this->user->tokens()->where('token', $data['access_token'])->first();
|
|
|
|
expect($tokenRecord)->not->toBeNull();
|
|
expect($tokenRecord->application_id)->toBe($this->application->id);
|
|
expect($tokenRecord->ip)->toBe('127.0.0.1'); // Default test IP
|
|
expect($tokenRecord->user_agent)->toBe('Test Browser/1.0');
|
|
expect($tokenRecord->issued_at)->not->toBeNull();
|
|
expect($tokenRecord->expires_at)->not->toBeNull();
|
|
});
|
|
|
|
it('sets access token expiration to 1 month', function () {
|
|
$this->actingAs($this->user);
|
|
|
|
$authCode = Str::random(40);
|
|
Cache::put("auth_code:$authCode", [
|
|
'user_id' => $this->user->id,
|
|
'client_id' => $this->application->id,
|
|
'scope' => 'openid',
|
|
], now()->addMinutes(5));
|
|
|
|
$before = now();
|
|
|
|
$response = $this->post('/auth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $authCode,
|
|
'redirect_uri' => $this->application->redirect_uri,
|
|
'client_id' => $this->application->client_id,
|
|
'client_secret' => $this->application->client_secret,
|
|
]);
|
|
|
|
$after = now();
|
|
$data = $response->json();
|
|
|
|
$tokenRecord = $this->user->tokens()->where('token', $data['access_token'])->first();
|
|
|
|
// Token should expire approximately 1 month from now
|
|
$expectedExpiry = $before->addMonth();
|
|
expect($tokenRecord->expires_at)->toBeBetween($expectedExpiry, $after->addMonth());
|
|
});
|
|
});
|