@@ -117,16 +117,15 @@ await context.UsingTenantScopeAsync(async scope =>
117117 Assert . Contains ( "orchauth_" + shellSettings . Name , cookies . Keys ) ;
118118
119119 var codeVerifier = GenerateCodeVerifier ( ) ;
120- var challengeCode = GenerateCodeChallenge ( codeVerifier ) ;
120+ var codeChallenge = GenerateCodeChallenge ( codeVerifier ) ;
121121 var requestData = new Dictionary < string , string >
122122 {
123123 { "client_id" , clientId } ,
124124 { "response_type" , "code" } ,
125125 { "redirect_uri" , redirectUri } ,
126126 { "scope" , "openid offline_access" } ,
127127 { "code_challenge_method" , "S256" } ,
128- { "code_verifier" , codeVerifier } ,
129- { "code_challenge" , challengeCode } ,
128+ { "code_challenge" , codeChallenge } ,
130129 } ;
131130
132131 var authorizeRequestMessage = HttpRequestHelper . CreatePostMessage ( "connect/authorize" , requestData ) ;
@@ -163,17 +162,163 @@ await context.UsingTenantScopeAsync(async scope =>
163162 var tokens = new ConcurrentBag < string > ( ) ;
164163
165164 // One one task should succeed since OpenId will only allow one access_token exchange for every authorization_code.
166- var taskOne = ExchangeCodeForTokenAsync ( httpClient , cookies , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
167- var taskTwo = ExchangeCodeForTokenAsync ( httpClient , cookies , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
168- var taskThree = ExchangeCodeForTokenAsync ( httpClient , cookies , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
165+ var taskOne = ExchangeCodeForTokenAsync ( httpClient , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
166+ var taskTwo = ExchangeCodeForTokenAsync ( httpClient , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
167+ var taskThree = ExchangeCodeForTokenAsync ( httpClient , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
169168
170169 await Task . WhenAll ( taskOne , taskTwo , taskThree ) ;
171170
172171 Assert . Single ( tokens ) ;
173172 } ) ;
174173 }
175174
176- private static async Task ExchangeCodeForTokenAsync ( HttpClient httpClient , IDictionary < string , string > cookies , string authorizationCode , string clientId , string redirectUri , string codeVerifier , ConcurrentBag < string > tokens )
175+ [ Fact ]
176+ public async Task OpenId_CodeFlowWithPushedAuthorizationRequests_CanExchangeAuthorizationCodeForAccessTokenOnlyOnce ( )
177+ {
178+ var context = new SiteContext ( ) ;
179+
180+ await context . InitializeAsync ( ) ;
181+
182+ var redirectUri = context . Client . BaseAddress . ToString ( ) + "signin-oidc" ;
183+
184+ var clientId = "test_id" ;
185+
186+ var recipeSteps = new JsonArray
187+ {
188+ new JsonObject
189+ {
190+ { "name" , "Feature" } ,
191+ { "enable" , new JsonArray (
192+ "OrchardCore.Users" ,
193+ "OrchardCore.OpenId.Server" ,
194+ "OrchardCore.OpenId.Validation" ,
195+ "OrchardCore.OpenId" )
196+ } ,
197+ } ,
198+ new JsonObject
199+ {
200+ { "name" , "OpenIdApplication" } ,
201+ { "ClientId" , clientId } ,
202+ { "DisplayName" , "Test Application" } ,
203+ { "Type" , "public" } ,
204+ { "ConsentType" , "implicit" } ,
205+ { "AllowAuthorizationCodeFlow" , true } ,
206+ { "RequireProofKeyForCodeExchange" , true } ,
207+ { "RequirePushedAuthorizationRequests" , true } ,
208+ { "AllowRefreshTokenFlow" , true } ,
209+ { "RedirectUris" , redirectUri } ,
210+ } ,
211+ } ;
212+
213+ var recipe = new JsonObject
214+ {
215+ { "steps" , recipeSteps } ,
216+ } ;
217+
218+ await RecipeHelpers . RunRecipeAsync ( context , recipe ) ;
219+
220+ await context . UsingTenantScopeAsync ( async scope =>
221+ {
222+ var featureManager = scope . ServiceProvider . GetService < IShellFeaturesManager > ( ) ;
223+
224+ Assert . True ( await featureManager . IsFeatureEnabledAsync ( "OrchardCore.Users" ) ) ;
225+ Assert . True ( await featureManager . IsFeatureEnabledAsync ( "OrchardCore.OpenId.Server" ) ) ;
226+ Assert . True ( await featureManager . IsFeatureEnabledAsync ( "OrchardCore.OpenId.Validation" ) ) ;
227+ Assert . True ( await featureManager . IsFeatureEnabledAsync ( "OrchardCore.OpenId" ) ) ;
228+
229+ var httpClient = context . Client ;
230+
231+ var session = scope . ServiceProvider . GetRequiredService < YesSql . ISession > ( ) ;
232+
233+ var applications = await session . Query < OpenIdApplication , OpenIdApplicationIndex > ( OpenIdApplication . OpenIdCollection ) . ListAsync ( ) ;
234+
235+ Assert . Single ( applications ) ;
236+
237+ var application = applications . First ( ) ;
238+ Assert . True ( application . ClientId == clientId ) ;
239+ Assert . Contains ( redirectUri , application . RedirectUris ) ;
240+ Assert . Equal ( "implicit" , application . ConsentType ) ;
241+ Assert . Contains ( OpenIddictConstants . Permissions . GrantTypes . AuthorizationCode , application . Permissions ) ;
242+ Assert . Contains ( OpenIddictConstants . Permissions . GrantTypes . RefreshToken , application . Permissions ) ;
243+ Assert . Contains ( OpenIddictConstants . Permissions . Endpoints . Authorization , application . Permissions ) ;
244+ Assert . Contains ( OpenIddictConstants . Permissions . Endpoints . PushedAuthorization , application . Permissions ) ;
245+ Assert . Contains ( OpenIddictConstants . Permissions . Endpoints . Token , application . Permissions ) ;
246+ Assert . Contains ( OpenIddictConstants . Permissions . ResponseTypes . Code , application . Permissions ) ;
247+
248+ Assert . Contains ( OpenIddictConstants . Requirements . Features . ProofKeyForCodeExchange , application . Requirements ) ;
249+ Assert . Contains ( OpenIddictConstants . Requirements . Features . PushedAuthorizationRequests , application . Requirements ) ;
250+
251+ // Visit the login page to get the AntiForgery token.
252+ var loginGetRequest = await httpClient . GetAsync ( "Login" , CancellationToken . None ) ;
253+
254+ var loginFormData = new Dictionary < string , string >
255+ {
256+ { "__RequestVerificationToken" , await AntiForgeryHelper . ExtractAntiForgeryToken ( loginGetRequest ) } ,
257+ { $ "{ nameof ( LoginForm ) } .{ nameof ( LoginViewModel . UserName ) } ", "admin" } ,
258+ { $ "{ nameof ( LoginForm ) } .{ nameof ( LoginViewModel . Password ) } ", "Password01_" } ,
259+ } ;
260+
261+ var shellSettings = scope . ServiceProvider . GetService < ShellSettings > ( ) ;
262+
263+ var loginPostRequest = HttpRequestHelper . CreatePostMessageWithCookies ( $ "Login?ReturnUrl=/{ shellSettings . RequestUrlPrefix } ?loggedIn=true", loginFormData , loginGetRequest ) ;
264+
265+ // Login
266+ var loginPostResponse = await httpClient . SendAsync ( loginPostRequest , CancellationToken . None ) ;
267+
268+ Assert . Equal ( HttpStatusCode . Redirect , loginPostResponse . StatusCode ) ;
269+
270+ var loginRequestRedirectToLocation = loginPostResponse . Headers . Location ? . ToString ( ) ;
271+
272+ Assert . NotEmpty ( loginRequestRedirectToLocation ) ;
273+ Assert . Contains ( "loggedIn=true" , loginRequestRedirectToLocation ) ;
274+
275+ var cookies = CookiesHelper . ExtractCookies ( loginPostResponse ) ;
276+
277+ Assert . Contains ( "orchauth_" + shellSettings . Name , cookies . Keys ) ;
278+
279+ var codeVerifier = GenerateCodeVerifier ( ) ;
280+ var codeChallenge = GenerateCodeChallenge ( codeVerifier ) ;
281+
282+ var requestData = new Dictionary < string , string >
283+ {
284+ { "client_id" , clientId } ,
285+ { "request_uri" , await GetRequestUriAsync ( httpClient , clientId , redirectUri , codeChallenge ) }
286+ } ;
287+
288+ var authorizeRequestMessage = HttpRequestHelper . CreatePostMessage ( "connect/authorize" , requestData ) ;
289+ CookiesHelper . AddCookiesToRequest ( authorizeRequestMessage , cookies ) ;
290+
291+ Assert . True ( authorizeRequestMessage . Headers . Contains ( "Cookie" ) , "Cookie header is missing from request." ) ;
292+
293+ var authorizeResponse = await httpClient . SendAsync ( authorizeRequestMessage , CancellationToken . None ) ;
294+
295+ Assert . Equal ( HttpStatusCode . Redirect , authorizeResponse . StatusCode ) ;
296+
297+ var finalRedirect = authorizeResponse . Headers . Location ? . ToString ( ) ;
298+
299+ Assert . NotEmpty ( finalRedirect ) ;
300+ Assert . StartsWith ( redirectUri , finalRedirect ) ;
301+
302+ // Extract the authorization code from the query string.
303+ var queryParameters = HttpUtility . ParseQueryString ( new Uri ( finalRedirect ) . Query ) ;
304+ var authorizationCode = queryParameters [ "code" ] ;
305+
306+ Assert . NotEmpty ( authorizationCode ) ;
307+
308+ var tokens = new ConcurrentBag < string > ( ) ;
309+
310+ // One one task should succeed since OpenId will only allow one access_token exchange for every authorization_code.
311+ var taskOne = ExchangeCodeForTokenAsync ( httpClient , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
312+ var taskTwo = ExchangeCodeForTokenAsync ( httpClient , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
313+ var taskThree = ExchangeCodeForTokenAsync ( httpClient , authorizationCode , clientId , redirectUri , codeVerifier , tokens ) ;
314+
315+ await Task . WhenAll ( taskOne , taskTwo , taskThree ) ;
316+
317+ Assert . Single ( tokens ) ;
318+ } ) ;
319+ }
320+
321+ private static async Task ExchangeCodeForTokenAsync ( HttpClient httpClient , string authorizationCode , string clientId , string redirectUri , string codeVerifier , ConcurrentBag < string > tokens )
177322 {
178323 var data = new Dictionary < string , string > ( )
179324 {
@@ -185,7 +330,6 @@ private static async Task ExchangeCodeForTokenAsync(HttpClient httpClient, IDict
185330 } ;
186331
187332 var request = HttpRequestHelper . CreatePostMessage ( "connect/token" , data ) ;
188- CookiesHelper . AddCookiesToRequest ( request , cookies ) ;
189333
190334 var tokenResponse = await httpClient . SendAsync ( request , CancellationToken . None ) ;
191335
@@ -201,6 +345,36 @@ private static async Task ExchangeCodeForTokenAsync(HttpClient httpClient, IDict
201345 }
202346 }
203347
348+ private static async Task < string > GetRequestUriAsync ( HttpClient httpClient , string clientId , string redirectUri , string codeChallenge )
349+ {
350+ var data = new Dictionary < string , string > ( )
351+ {
352+ { "client_id" , clientId } ,
353+ { "response_type" , "code" } ,
354+ { "redirect_uri" , redirectUri } ,
355+ { "scope" , "openid offline_access" } ,
356+ { "code_challenge_method" , "S256" } ,
357+ { "code_challenge" , codeChallenge } ,
358+ } ;
359+
360+ var request = HttpRequestHelper . CreatePostMessage ( "connect/par" , data ) ;
361+
362+ var response = await httpClient . SendAsync ( request , CancellationToken . None ) ;
363+ if ( response . IsSuccessStatusCode )
364+ {
365+ var result = await response . Content . ReadFromJsonAsync < JsonObject > ( ) ;
366+ var value = result [ "request_uri" ] ? . ToString ( ) ;
367+
368+ Assert . NotEmpty ( value ) ;
369+
370+ return value ;
371+ }
372+ else
373+ {
374+ throw new InvalidOperationException ( "An error response was returned by the pushed authorization endpoint." ) ;
375+ }
376+ }
377+
204378 private static string GenerateCodeVerifier ( )
205379 {
206380 var randomBytes = new byte [ 32 ] ;
0 commit comments