Skip to content

Commit 577107d

Browse files
authored
Merge pull request #5 from PlaceOS/PPT-2536-ensure-matching-continue
PPT-2536: enforce ensure_matching + fix continue lost through SSO
2 parents 71b6bda + 3464122 commit 577107d

3 files changed

Lines changed: 193 additions & 1 deletion

File tree

spec/controllers/oauth_provider_flows_spec.cr

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/placeos-auth/controllers/provider_callbacks.cr

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ module PlaceOS::Auth
127127
end
128128
end
129129

130+
# Pop the pre-login `continue` target NOW: `session_user` below can tear
131+
# down a stale session, and `remove_session`/`new_session` deliberately
132+
# clear the session store — all of which used to wipe the target stashed
133+
# by `/auth/login` before it was read, so every SSO login landed on "/"
134+
# instead of returning to the app that started it. Ruby preserved
135+
# `continue` across the provider round-trip; consuming it early here
136+
# restores that.
137+
continue_target = consume_continue
138+
130139
redirect_uri = callback_uri(provider, id)
131140
engine = ::MultiAuth.make(provider, redirect_uri, id)
132141

@@ -149,6 +158,21 @@ module PlaceOS::Auth
149158
return
150159
end
151160

161+
# Enforce the OAuth strategy's hosted-domain / attribute restriction
162+
# (`ensure_matching`) against the userinfo we just fetched. The legacy
163+
# Ruby strategy did this in `generic_oauth#raw_info`, raising
164+
# "Invalid Hosted Domain" which OmniAuth turned into a `/auth/failure`
165+
# redirect. Without this, an authority that restricted OAuth logins to
166+
# a corporate domain would admit any account from the provider. SAML
167+
# flows share this action but have no oauth strat, so gate on the
168+
# oauth2 provider.
169+
if provider == ExternalProviders::OAUTH2_PROVIDER &&
170+
!ExternalProviders.ensure_matching?(id, oauth_user.raw_json)
171+
Log.warn { {action: "provider_callbacks.callback", message: "ensure_matching restriction rejected login", provider: provider} }
172+
redirect_to "/auth/failure", :found
173+
return
174+
end
175+
152176
session_user_for_link = session_user
153177
result = Utils::OAuthUserMapper.map(
154178
authority: authority,
@@ -163,7 +187,7 @@ module PlaceOS::Auth
163187

164188
LoginEvents.record_login(result.user, oauth_user.provider)
165189

166-
target = consume_continue || "/"
190+
target = continue_target || "/"
167191
redirect_to target.gsub(' ', "%20"), :see_other
168192
end
169193

src/placeos-auth/external_providers.cr

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,59 @@ module PlaceOS::Auth::ExternalProviders
7272
)
7373
end
7474

75+
# Enforce the OAuth strategy's `ensure_matching` hosted-domain /
76+
# attribute restriction against the userinfo JSON the provider returned.
77+
# Mirrors the legacy Ruby `OmniAuth::Strategies::GenericOauth#raw_info`
78+
# ("Invalid Hosted Domain"): for *every* configured field, at least one
79+
# of that field's values must match (case-insensitive, unanchored regex)
80+
# at least one of its configured patterns. A missing field — or a field
81+
# whose values match no pattern — fails. Returns `true` when there is
82+
# nothing to enforce (no strat, or empty config) or when every field
83+
# passes; `false` otherwise. Fails closed: an unparseable pattern simply
84+
# cannot match anything.
85+
def self.ensure_matching?(provider_id : String?, raw_json : String) : Bool
86+
strat = find_oauth_strat(provider_id)
87+
return true if strat.nil?
88+
89+
required = strat.ensure_matching
90+
return true if required.empty?
91+
92+
info = JSON.parse(raw_json)
93+
required.all? do |field, patterns|
94+
values = field_values(info, field)
95+
regexes = patterns.compact_map do |pattern|
96+
Regex.new(pattern, Regex::CompileOptions::IGNORE_CASE) rescue nil
97+
end
98+
values.any? { |value| regexes.any?(&.matches?(value)) }
99+
end
100+
end
101+
102+
# Coerce a userinfo JSON field into the list of string values to test.
103+
# Matches Ruby's `Array(inf[field])`: missing/null -> [], scalar ->
104+
# [scalar], array -> its (stringified) elements.
105+
private def self.field_values(info : JSON::Any, field : String) : Array(String)
106+
value = info[field]?
107+
return [] of String if value.nil?
108+
109+
case raw = value.raw
110+
when String
111+
[raw]
112+
when Array(JSON::Any)
113+
raw.compact_map do |element|
114+
element_raw = element.raw
115+
case element_raw
116+
when Nil then nil
117+
when String then element_raw
118+
else element_raw.to_s
119+
end
120+
end
121+
when Nil
122+
[] of String
123+
else
124+
[raw.to_s]
125+
end
126+
end
127+
75128
# ---- SAML ---------------------------------------------------------
76129

77130
def self.find_saml_strat(provider_id : String?) : ::PlaceOS::Model::SamlAuthentication?

0 commit comments

Comments
 (0)