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