From 8e0abedbbbed281cda5b7f91fae44ea162fee0e2 Mon Sep 17 00:00:00 2001 From: Javier Feliz Date: Thu, 31 Jul 2025 04:23:02 -0400 Subject: [PATCH] WIP --- tests/Feature/OIDCControllerTest.php | 401 +++++++++++++++++++++++++++ tests/Feature/OIDCJWTTest.php | 310 +++++++++++++++++++++ tests/TestCase.php | 3 +- 3 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/OIDCControllerTest.php create mode 100644 tests/Feature/OIDCJWTTest.php diff --git a/tests/Feature/OIDCControllerTest.php b/tests/Feature/OIDCControllerTest.php new file mode 100644 index 0000000..443ae3a --- /dev/null +++ b/tests/Feature/OIDCControllerTest.php @@ -0,0 +1,401 @@ +user = User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'preferred_username' => 'testuser', + ]); + + $this->application = Application::factory()->create([ + 'name' => 'Test App', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'redirect_uri' => 'https://example.com/callback', + ]); + + // Create RSA key pair for testing + $this->createTestKeys(); +}); + +afterEach(function () { + // Clean up test keys + $this->cleanupTestKeys(); +}); + +// Helper function to create test RSA keys +function createTestKeys() +{ + $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']); +} + +// Helper function to clean up test keys +function cleanupTestKeys() +{ + $privateKeyPath = storage_path('oauth/private.pem'); + $publicKeyPath = storage_path('oauth/public.pem'); + + if (file_exists($privateKeyPath)) { + unlink($privateKeyPath); + } + if (file_exists($publicKeyPath)) { + unlink($publicKeyPath); + } +} + +describe('OIDC Authorization Endpoint', function () { + it('redirects to consent screen with valid parameters', function () { + $this->actingAs($this->user); + + $response = $this->get('/auth/authorize?' . http_build_query([ + 'client_id' => $this->application->client_id, + 'redirect_uri' => $this->application->redirect_uri, + 'response_type' => 'code', + 'scope' => 'openid email profile', + 'state' => 'test-state', + ])); + + $response->assertRedirect(route('auth.confirm')); + + // Verify session data is set + $this->assertEquals($this->application->id, session('app_id')); + expect(session('redirect_on_confirm'))->toContain($this->application->redirect_uri); + expect(session('redirect_on_confirm'))->toContain('code='); + expect(session('redirect_on_confirm'))->toContain('state=test-state'); + }); + + it('fails with invalid client_id', function () { + $this->actingAs($this->user); + + $response = $this->get('/auth/authorize?' . http_build_query([ + 'client_id' => 'invalid-client-id', + 'redirect_uri' => $this->application->redirect_uri, + 'response_type' => 'code', + ])); + + $response->assertStatus(404); + }); + + it('fails with redirect_uri mismatch', function () { + $this->actingAs($this->user); + + $response = $this->get('/auth/authorize?' . http_build_query([ + 'client_id' => $this->application->client_id, + 'redirect_uri' => 'https://evil.com/callback', + 'response_type' => 'code', + ])); + + $response->assertStatus(403); + }); + + it('requires authentication', function () { + $response = $this->get('/auth/authorize?' . http_build_query([ + 'client_id' => $this->application->client_id, + 'redirect_uri' => $this->application->redirect_uri, + 'response_type' => 'code', + ])); + + $response->assertRedirect(route('login')); + }); + + it('supports PKCE parameters', function () { + $this->actingAs($this->user); + + $codeVerifier = Str::random(128); + $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + + $response = $this->get('/auth/authorize?' . http_build_query([ + 'client_id' => $this->application->client_id, + 'redirect_uri' => $this->application->redirect_uri, + 'response_type' => 'code', + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + ])); + + $response->assertRedirect(route('auth.confirm')); + }); +}); + +describe('OIDC Token Endpoint', function () { + beforeEach(function () { + // Set up authorization code in cache + $this->authCode = Str::random(40); + $this->payload = [ + 'user_id' => $this->user->id, + 'client_id' => $this->application->id, + 'scope' => 'openid email profile', + 'nonce' => 'test-nonce', + ]; + Cache::put("auth_code:{$this->authCode}", $this->payload, now()->addMinutes(5)); + }); + + it('exchanges authorization code for tokens with client credentials', function () { + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $this->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(); + + expect($data)->toHaveKeys(['access_token', 'token_type', 'expires_in', 'id_token']); + expect($data['token_type'])->toBe('Bearer'); + expect($data['expires_in'])->toBe(3600); + + // Verify authentication token was created + $this->assertDatabaseHas('authentication_tokens', [ + 'user_id' => $this->user->id, + 'application_id' => $this->application->id, + 'token' => $data['access_token'], + ]); + + // Verify auth code was consumed + expect(Cache::get("auth_code:{$this->authCode}"))->toBeNull(); + }); + + it('exchanges authorization code for tokens with PKCE', function () { + $codeVerifier = Str::random(128); + $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + + // Update cache with PKCE parameters + $this->payload['code_challenge'] = $codeChallenge; + $this->payload['code_challenge_method'] = 'S256'; + Cache::put("auth_code:{$this->authCode}", $this->payload, now()->addMinutes(5)); + + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $this->authCode, + 'redirect_uri' => $this->application->redirect_uri, + 'code_verifier' => $codeVerifier, + ]); + + $response->assertStatus(200); + $data = $response->json(); + + expect($data)->toHaveKeys(['access_token', 'token_type', 'expires_in', 'id_token']); + }); + + it('fails with invalid authorization code', function () { + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => 'invalid-code', + 'redirect_uri' => $this->application->redirect_uri, + 'client_id' => $this->application->client_id, + 'client_secret' => $this->application->client_secret, + ]); + + $response->assertStatus(403); + }); + + it('fails with expired authorization code', function () { + // Set expired code + Cache::put("auth_code:expired", $this->payload, now()->subMinute()); + + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => 'expired', + 'redirect_uri' => $this->application->redirect_uri, + 'client_id' => $this->application->client_id, + 'client_secret' => $this->application->client_secret, + ]); + + $response->assertStatus(403); + }); + + it('fails with invalid client credentials', function () { + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $this->authCode, + 'redirect_uri' => $this->application->redirect_uri, + 'client_id' => $this->application->client_id, + 'client_secret' => 'wrong-secret', + ]); + + $response->assertStatus(403); + }); + + it('fails with invalid PKCE code_verifier', function () { + $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', 'correct-verifier', true)), '+/', '-_'), '='); + + $this->payload['code_challenge'] = $codeChallenge; + $this->payload['code_challenge_method'] = 'S256'; + Cache::put("auth_code:{$this->authCode}", $this->payload, now()->addMinutes(5)); + + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $this->authCode, + 'redirect_uri' => $this->application->redirect_uri, + 'code_verifier' => 'wrong-verifier', + ]); + + $response->assertStatus(403); + }); + + it('fails with redirect_uri mismatch', function () { + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $this->authCode, + 'redirect_uri' => 'https://evil.com/callback', + 'client_id' => $this->application->client_id, + 'client_secret' => $this->application->client_secret, + ]); + + $response->assertStatus(403); + }); + + it('fails with missing authentication', function () { + $response = $this->post('/auth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $this->authCode, + 'redirect_uri' => $this->application->redirect_uri, + ]); + + $response->assertStatus(403); + }); +}); + +describe('OIDC UserInfo Endpoint', function () { + beforeEach(function () { + $this->token = AuthenticationToken::create([ + 'user_id' => $this->user->id, + 'application_id' => $this->application->id, + 'token' => Str::random(64), + 'issued_at' => now(), + 'expires_at' => now()->addMonth(), + 'ip' => '127.0.0.1', + 'user_agent' => 'Test Agent', + ]); + }); + + it('returns user information with valid token', function () { + $response = $this->get('/auth/userinfo', [ + 'Authorization' => 'Bearer ' . $this->token->token, + ]); + + $response->assertStatus(200); + $data = $response->json(); + + expect($data)->toBe([ + 'sub' => (string) $this->user->id, + 'email' => $this->user->email, + 'name' => $this->user->name, + 'preferred_username' => $this->user->preferred_username, + 'picture' => null, // No avatar set in test + ]); + }); + + it('returns user information with avatar', function () { + $this->user->update(['avatar' => 'test-avatar.jpg']); + + $response = $this->get('/auth/userinfo', [ + 'Authorization' => 'Bearer ' . $this->token->token, + ]); + + $response->assertStatus(200); + $data = $response->json(); + + expect($data['picture'])->toContain('test-avatar.jpg'); + }); + + it('fails with missing authorization header', function () { + $response = $this->get('/auth/userinfo'); + + $response->assertStatus(400); + expect($response->json()['error'])->toBe('invalid_request'); + }); + + it('fails with invalid authorization header format', function () { + $response = $this->get('/auth/userinfo', [ + 'Authorization' => 'Basic invalid-format', + ]); + + $response->assertStatus(400); + expect($response->json()['error'])->toBe('invalid_request'); + }); + + it('fails with invalid token', function () { + $response = $this->get('/auth/userinfo', [ + 'Authorization' => 'Bearer invalid-token', + ]); + + $response->assertStatus(401); + expect($response->json()['error'])->toBe('invalid_token'); + }); +}); + +describe('OIDC JWKS Endpoint', function () { + it('returns public keys in JWKS format', function () { + $response = $this->get('/auth/keys'); + + $response->assertStatus(200); + $data = $response->json(); + + expect($data)->toHaveKey('keys'); + expect($data['keys'])->toBeArray(); + expect($data['keys'][0])->toHaveKeys(['kty', 'use', 'alg', 'kid', 'n', 'e']); + expect($data['keys'][0]['kty'])->toBe('RSA'); + expect($data['keys'][0]['use'])->toBe('sig'); + expect($data['keys'][0]['alg'])->toBe('RS256'); + }); +}); + +describe('OIDC OpenID Configuration', function () { + it('returns openid configuration', function () { + $response = $this->get('/.well-known/openid-configuration'); + + $response->assertStatus(200); + $data = $response->json(); + + $expectedKeys = [ + 'issuer', + 'authorization_endpoint', + 'token_endpoint', + 'useringo_endpoint', + 'scopes_supported', + 'response_types_supported', + 'jwks_uri', + 'id_token_signing_alg_values_supported', + 'claims_supported' + ]; + + expect($data)->toHaveKeys($expectedKeys); + expect($data['issuer'])->toBe(config('app.url')); + expect($data['scopes_supported'])->toContain('openid'); + expect($data['response_types_supported'])->toContain('code'); + expect($data['id_token_signing_alg_values_supported'])->toContain('RS256'); + }); +}); + +describe('OIDC Logout Endpoint', function () { + it('returns logout view', function () { + $response = $this->get('/auth/logout'); + + $response->assertStatus(200); + $response->assertViewIs('logged-out'); + }); +}); diff --git a/tests/Feature/OIDCJWTTest.php b/tests/Feature/OIDCJWTTest.php new file mode 100644 index 0000000..1c74581 --- /dev/null +++ b/tests/Feature/OIDCJWTTest.php @@ -0,0 +1,310 @@ +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()); + }); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..d7c5d5f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,10 @@ namespace Tests; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + use RefreshDatabase; }