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()); }); });