@@ -423,6 +423,121 @@ module PlaceOS::Auth
423423 end
424424 end
425425
426+ # ---- post-login continue + ensure_matching (PPT-2536) --------------
427+
428+ session_cookie = - > (result : HTTP ::Client ::Response , fallback : String ) {
429+ sc = result.cookies[PlaceOS ::Auth ::SESSION_COOKIE_NAME ]?
430+ sc ? " #{ sc.name } =#{ sc.value } " : fallback
431+ }
432+
433+ google_urls = - > (scopes : String , mappings : Hash (String , String )) {
434+ new_oauth_strat.call(
435+ " https://accounts.google.com" , " /o/oauth2/v2/auth" , " /token" ,
436+ " https://openidconnect.googleapis.com/v1/userinfo" , scopes, mappings,
437+ )
438+ }
439+
440+ describe " post-login continue" do
441+ it " returns to the app that started the login (continue survives the SSO round-trip)" do
442+ strat = google_urls.call(" openid email" , {" uid" => " sub" , " email" => " email" })
443+ uid = " google-cont-#{ Random .rand(999999 )} "
444+
445+ WebMock .stub(:post , " https://accounts.google.com/token" ).to_return(
446+ status: 200 , headers: json_headers,
447+ body: {access_token: " g-access" , token_type: " Bearer" , expires_in: 3600 }.to_json,
448+ )
449+ WebMock .stub(:get , " https://openidconnect.googleapis.com/v1/userinfo" ).to_return(
450+ status: 200 , headers: json_headers,
451+ body: {sub: uid, email: " cont-#{ Random .rand(999999 )} @localhost" }.to_json,
452+ )
453+
454+ # Hop 1: the login entrypoint stores `continue` on the session and
455+ # bounces to the provider kickoff.
456+ login = client.get(
457+ " /auth/login?provider=oauth2&id=#{ URI .encode_www_form(strat.id.as(String ))} &continue=%2Fbackoffice%2F" ,
458+ headers: HTTP ::Headers {" Host" => " localhost" },
459+ )
460+ login.status_code.should eq 303
461+ cookie = session_cookie.call(login, " " )
462+ cookie.should_not be_empty
463+
464+ # Hop 2: kickoff -> IdP redirect (state minted, session updated).
465+ kick = client.get(login.headers[" Location" ], headers: HTTP ::Headers {
466+ " Host" => " localhost" , " Cookie" => cookie,
467+ })
468+ kick.status_code.should eq 303
469+ state = URI ::Params .parse(kick.headers[" Location" ].split('?' , 2 ).last)[" state" ]
470+ cookie = session_cookie.call(kick, cookie)
471+
472+ # Hop 3: provider callback -> must land back on /backoffice/.
473+ result = client.get(
474+ " /auth/oauth2/callback?id=#{ URI .encode_www_form(strat.id.as(String ))} &code=g-code&state=#{ state } " ,
475+ headers: HTTP ::Headers {" Host" => " localhost" , " Cookie" => cookie},
476+ )
477+ result.status_code.should eq 303
478+ result.headers[" Location" ].should eq " /backoffice/"
479+ ensure
480+ cleanup_login.call(uid) if uid
481+ strat.try & .destroy
482+ end
483+ end
484+
485+ describe " ensure_matching" do
486+ make_restricted_strat = - > {
487+ strat = google_urls.call(" openid email" , {" uid" => " sub" , " email" => " email" })
488+ strat.ensure_matching = {" email" => [" @corp.example$" ]}
489+ strat.save!
490+ strat
491+ }
492+
493+ stub_round_trip = - > (email : String , uid : String ) {
494+ WebMock .stub(:post , " https://accounts.google.com/token" ).to_return(
495+ status: 200 , headers: json_headers,
496+ body: {access_token: " g-access" , token_type: " Bearer" , expires_in: 3600 }.to_json,
497+ )
498+ WebMock .stub(:get , " https://openidconnect.googleapis.com/v1/userinfo" ).to_return(
499+ status: 200 , headers: json_headers,
500+ body: {sub: uid, email: email}.to_json,
501+ )
502+ }
503+
504+ it " rejects a login whose userinfo fails the restriction (-> /auth/failure)" do
505+ strat = make_restricted_strat.call
506+ uid = " google-em-reject-#{ Random .rand(999999 )} "
507+ stub_round_trip.call(" eve@evil.test" , uid)
508+
509+ k = kickoff.call(strat.id.as(String ))
510+ result = client.get(
511+ " /auth/oauth2/callback?id=#{ URI .encode_www_form(strat.id.as(String ))} &code=g-code&state=#{ k[:state ] } " ,
512+ headers: HTTP ::Headers {" Host" => " localhost" , " Cookie" => k[:cookie ]},
513+ )
514+ result.status_code.should eq 302
515+ result.headers[" Location" ].should eq " /auth/failure"
516+ # and no local account was minted for the rejected identity
517+ ::PlaceOS ::Model ::UserAuthLookup .find?(" auth-#{ authority_id.call } -oauth2-#{ uid } " ).should be_nil
518+ ensure
519+ cleanup_login.call(uid) if uid
520+ strat.try & .destroy
521+ end
522+
523+ it " admits a login whose userinfo satisfies the restriction" do
524+ strat = make_restricted_strat.call
525+ uid = " google-em-pass-#{ Random .rand(999999 )} "
526+ stub_round_trip.call(" alice@corp.example" , uid)
527+
528+ k = kickoff.call(strat.id.as(String ))
529+ result = client.get(
530+ " /auth/oauth2/callback?id=#{ URI .encode_www_form(strat.id.as(String ))} &code=g-code&state=#{ k[:state ] } " ,
531+ headers: HTTP ::Headers {" Host" => " localhost" , " Cookie" => k[:cookie ]},
532+ )
533+ result.status_code.should eq 303
534+ ::PlaceOS ::Model ::UserAuthLookup .find?(" auth-#{ authority_id.call } -oauth2-#{ uid } " ).should_not be_nil
535+ ensure
536+ cleanup_login.call(uid) if uid
537+ strat.try & .destroy
538+ end
539+ end
540+
426541 # ---- authorize_params + info_mappings comma-fallback (PPT-2536) ----
427542
428543 describe " authorize_params" do
0 commit comments