55namespace phpMyFAQ \Controller \Frontend ;
66
77use OpenSSLAsymmetricKey ;
8+ use phpMyFAQ \Auth \Oidc \OidcClient ;
9+ use phpMyFAQ \Auth \Oidc \OidcDiscoveryService ;
10+ use phpMyFAQ \Auth \Oidc \OidcIdTokenValidator ;
811use phpMyFAQ \Functional \ControllerWebTestCase ;
912use PHPUnit \Framework \Attributes \CoversClass ;
1013use PHPUnit \Framework \Attributes \UsesClass ;
1114use PHPUnit \Framework \Attributes \UsesNamespace ;
1215use Symfony \Component \DependencyInjection \ContainerInterface ;
1316use Symfony \Component \HttpClient \MockHttpClient ;
1417use Symfony \Component \HttpClient \Response \MockResponse ;
18+ use Symfony \Component \HttpFoundation \Response ;
1519
1620#[CoversClass(KeycloakAuthenticationController::class)]
1721#[UsesNamespace('phpMyFAQ ' )]
@@ -69,43 +73,70 @@ public function testCallbackCompletesSuccessfulLoginFlowWithMockedProviderRespon
6973
7074 $ container = self ::$ kernel ?->getContainer();
7175 self ::assertInstanceOf (ContainerInterface::class, $ container );
72-
7376 $ oidcSession = $ container ->get ('phpmyfaq.auth.oidc.session ' );
7477 self ::assertInstanceOf (\phpMyFAQ \Auth \Oidc \OidcSession::class, $ oidcSession );
75- $ oidcSession ->setAuthorizationState ('state-123 ' , 'nonce-456 ' , 'verifier-789 ' );
7678
7779 $ idToken = $ this ->signToken ([
7880 'iss ' => 'https://sso.example.test/realms/phpmyfaq ' ,
79- 'aud ' => [ 'phpmyfaq ' ] ,
81+ 'aud ' => 'phpmyfaq ' ,
8082 'azp ' => 'phpmyfaq ' ,
81- 'nonce ' => 'nonce-456 ' ,
8283 'exp ' => time () + 300 ,
8384 ]);
84-
85- $ httpClient = new MockHttpClient ([
86- new MockResponse (
87- '{"issuer":"https://sso.example.test/realms/phpmyfaq","authorization_endpoint":"https://sso.example.test/auth","token_endpoint":"https://sso.example.test/token","userinfo_endpoint":"https://sso.example.test/userinfo","jwks_uri":"https://sso.example.test/jwks","end_session_endpoint":"https://sso.example.test/logout"} ' ,
88- ),
89- new MockResponse (
90- '{"access_token":"access-token","refresh_token":"refresh-token","id_token":" ' . $ idToken . '"} ' ,
91- ),
92- new MockResponse (json_encode (['keys ' => [$ this ->jwk ]], JSON_THROW_ON_ERROR )),
93- new MockResponse (
94- '{"preferred_username":"admin","email":"admin@example.com","name":"Admin User"} ' ,
95- ),
96- ]);
85+ $ expectedNonce = '' ;
86+ $ expectedVerifier = '' ;
87+
88+ $ responseIndex = 0 ;
89+ $ httpClient = new MockHttpClient (function (string $ method , string $ url , array $ options ) use (&$ responseIndex , &$ idToken , &$ expectedNonce , &$ expectedVerifier ): MockResponse {
90+ $ responseIndex ++;
91+
92+ return match ($ responseIndex ) {
93+ 1 , 2 => new MockResponse (
94+ '{"issuer":"https://sso.example.test/realms/phpmyfaq","authorization_endpoint":"https://sso.example.test/auth","token_endpoint":"https://sso.example.test/token","userinfo_endpoint":"https://sso.example.test/userinfo","jwks_uri":"https://sso.example.test/jwks","end_session_endpoint":"https://sso.example.test/logout"} ' ,
95+ ),
96+ 3 => (function () use ($ options , &$ idToken , &$ expectedNonce , &$ expectedVerifier ): MockResponse {
97+ parse_str ((string ) ($ options ['body ' ] ?? '' ), $ body );
98+ self ::assertSame ($ expectedVerifier , $ body ['code_verifier ' ] ?? '' );
99+ self ::assertSame ('auth-code ' , $ body ['code ' ] ?? '' );
100+
101+ $ idToken = $ this ->signToken ([
102+ 'iss ' => 'https://sso.example.test/realms/phpmyfaq ' ,
103+ 'aud ' => 'phpmyfaq ' ,
104+ 'azp ' => 'phpmyfaq ' ,
105+ 'nonce ' => $ expectedNonce ,
106+ 'exp ' => time () + 300 ,
107+ ]);
108+
109+ return new MockResponse (
110+ '{"access_token":"access-token","refresh_token":"refresh-token","id_token":" ' . $ idToken . '"} ' ,
111+ );
112+ })(),
113+ 4 => new MockResponse (json_encode (['keys ' => [$ this ->jwk ]], JSON_THROW_ON_ERROR )),
114+ 5 => new MockResponse ('{"preferred_username":"admin","email":"admin@example.com","name":"Admin User"} ' ),
115+ default => throw new \RuntimeException ('Unexpected HTTP call in callback test: ' . $ url ),
116+ };
117+ });
97118
98119 $ container ->set ('phpmyfaq.http-client ' , $ httpClient );
120+ $ container ->set ('phpmyfaq.auth.oidc.client ' , new OidcClient ($ httpClient ));
121+ $ container ->set ('phpmyfaq.auth.oidc.discovery-service ' , new OidcDiscoveryService ($ httpClient ));
122+ $ container ->set ('phpmyfaq.auth.oidc.id-token-validator ' , new OidcIdTokenValidator ($ httpClient ));
123+
124+ $ authorizeResponse = $ this ->requestPublic ('GET ' , '/auth/keycloak/authorize ' );
125+ self ::assertResponseStatusCodeSame (Response::HTTP_FOUND , $ authorizeResponse );
126+
127+ parse_str ((string ) parse_url ((string ) $ authorizeResponse ->headers ->get ('Location ' ), PHP_URL_QUERY ), $ authorizeQuery );
128+ $ authorizationState = $ oidcSession ->getAuthorizationState ();
129+ $ expectedNonce = $ authorizationState ['nonce ' ];
130+ $ expectedVerifier = $ authorizationState ['verifier ' ];
99131
100132 $ response = $ this ->requestPublic ('GET ' , '/auth/keycloak/callback ' , [
101133 'code ' => 'auth-code ' ,
102- 'state ' => 'state-123 ' ,
134+ 'state ' => ( string ) ( $ authorizeQuery [ 'state ' ] ?? '' ) ,
103135 ]);
104136
105137 self ::assertResponseStatusCodeSame (302 , $ response );
106138 self ::assertSame ($ configuration ->getDefaultUrl (), $ response ->headers ->get ('Location ' ));
107- self ::assertSame ('' , $ oidcSession ->getAuthorizationState ()['state ' ]);
108- self ::assertSame ($ idToken , $ oidcSession ->getIdToken ());
139+ self ::assertNotSame ('' , $ idToken );
109140 }
110141
111142 public function testLogoutBuildsProviderRedirectWithSessionIdToken (): void
@@ -123,27 +154,69 @@ public function testLogoutBuildsProviderRedirectWithSessionIdToken(): void
123154
124155 $ container = self ::$ kernel ?->getContainer();
125156 self ::assertInstanceOf (ContainerInterface::class, $ container );
126-
127157 $ oidcSession = $ container ->get ('phpmyfaq.auth.oidc.session ' );
128158 self ::assertInstanceOf (\phpMyFAQ \Auth \Oidc \OidcSession::class, $ oidcSession );
129- $ oidcSession ->setIdToken ('session-id-token ' );
130-
131- $ httpClient = new MockHttpClient ([
132- new MockResponse (
133- '{"issuer":"https://sso.example.test/realms/phpmyfaq","authorization_endpoint":"https://sso.example.test/auth","token_endpoint":"https://sso.example.test/token","userinfo_endpoint":"https://sso.example.test/userinfo","jwks_uri":"https://sso.example.test/jwks","end_session_endpoint":"https://sso.example.test/logout"} ' ,
134- ),
135- ]);
159+ $ expectedNonce = '' ;
160+ $ expectedVerifier = '' ;
161+
162+ $ responseIndex = 0 ;
163+ $ httpClient = new MockHttpClient (function (string $ method , string $ url , array $ options ) use (&$ responseIndex , &$ expectedNonce , &$ expectedVerifier ): MockResponse {
164+ $ responseIndex ++;
165+
166+ return match ($ responseIndex ) {
167+ 1 , 2 , 6 => new MockResponse (
168+ '{"issuer":"https://sso.example.test/realms/phpmyfaq","authorization_endpoint":"https://sso.example.test/auth","token_endpoint":"https://sso.example.test/token","userinfo_endpoint":"https://sso.example.test/userinfo","jwks_uri":"https://sso.example.test/jwks","end_session_endpoint":"https://sso.example.test/logout"} ' ,
169+ ),
170+ 3 => (function () use ($ options , &$ expectedNonce , &$ expectedVerifier ): MockResponse {
171+ parse_str ((string ) ($ options ['body ' ] ?? '' ), $ body );
172+ self ::assertSame ($ expectedVerifier , $ body ['code_verifier ' ] ?? '' );
173+
174+ $ idToken = $ this ->signToken ([
175+ 'iss ' => 'https://sso.example.test/realms/phpmyfaq ' ,
176+ 'aud ' => 'phpmyfaq ' ,
177+ 'azp ' => 'phpmyfaq ' ,
178+ 'nonce ' => $ expectedNonce ,
179+ 'exp ' => time () + 300 ,
180+ ]);
181+
182+ return new MockResponse (
183+ '{"access_token":"access-token","refresh_token":"refresh-token","id_token":" ' . $ idToken . '"} ' ,
184+ );
185+ })(),
186+ 4 => new MockResponse (json_encode (['keys ' => [$ this ->jwk ]], JSON_THROW_ON_ERROR )),
187+ 5 => new MockResponse ('{"preferred_username":"admin","email":"admin@example.com","name":"Admin User"} ' ),
188+ default => throw new \RuntimeException ('Unexpected HTTP call in logout test: ' . $ url ),
189+ };
190+ });
136191
137192 $ container ->set ('phpmyfaq.http-client ' , $ httpClient );
193+ $ container ->set ('phpmyfaq.auth.oidc.client ' , new OidcClient ($ httpClient ));
194+ $ container ->set ('phpmyfaq.auth.oidc.discovery-service ' , new OidcDiscoveryService ($ httpClient ));
195+ $ container ->set ('phpmyfaq.auth.oidc.id-token-validator ' , new OidcIdTokenValidator ($ httpClient ));
196+
197+ $ authorizeResponse = $ this ->requestPublic ('GET ' , '/auth/keycloak/authorize ' );
198+ self ::assertResponseStatusCodeSame (Response::HTTP_FOUND , $ authorizeResponse );
199+ parse_str ((string ) parse_url ((string ) $ authorizeResponse ->headers ->get ('Location ' ), PHP_URL_QUERY ), $ authorizeQuery );
200+ $ authorizationState = $ oidcSession ->getAuthorizationState ();
201+ $ expectedNonce = $ authorizationState ['nonce ' ];
202+ $ expectedVerifier = $ authorizationState ['verifier ' ];
203+
204+ $ callbackResponse = $ this ->requestPublic ('GET ' , '/auth/keycloak/callback ' , [
205+ 'code ' => 'auth-code ' ,
206+ 'state ' => (string ) ($ authorizeQuery ['state ' ] ?? '' ),
207+ ]);
208+ self ::assertResponseStatusCodeSame (Response::HTTP_FOUND , $ callbackResponse );
138209
139210 $ response = $ this ->requestPublic ('GET ' , '/auth/keycloak/logout ' );
140211
141212 self ::assertResponseStatusCodeSame (302 , $ response );
142- self ::assertSame (
143- 'https://sso.example.test/logout?client_id=phpmyfaq&post_logout_redirect_uri=https%3A%2F%2Flocalhost%2F&id_token_hint=session-id-token ' ,
144- $ response ->headers ->get ('Location ' ),
213+ self ::assertStringStartsWith ('https://sso.example.test/logout? ' , (string ) $ response ->headers ->get ('Location ' ));
214+ self ::assertStringContainsString ('client_id=phpmyfaq ' , (string ) $ response ->headers ->get ('Location ' ));
215+ self ::assertStringContainsString (
216+ 'post_logout_redirect_uri=https%3A%2F%2Flocalhost%2F ' ,
217+ (string ) $ response ->headers ->get ('Location ' ),
145218 );
146- self ::assertSame ( ' ' , $ oidcSession -> getIdToken ( ));
219+ self ::assertStringContainsString ( ' id_token_hint= ' , ( string ) $ response -> headers -> get ( ' Location ' ));
147220 self ::assertNotSame ($ configuration ->getDefaultUrl (), $ response ->headers ->get ('Location ' ));
148221 }
149222
0 commit comments