Skip to content

Commit 71b6bda

Browse files
authored
Merge pull request #4 from PlaceOS/PPT-2536-saml-relaystate
PPT-2536: fix SAML/ADFS login + OAuth callback lost-id (UAT-breaking)
2 parents 19ebade + 23be091 commit 71b6bda

5 files changed

Lines changed: 207 additions & 3 deletions

File tree

Dockerfile.e2e

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
ARG CRYSTAL_VERSION=latest
2+
3+
FROM placeos/crystal:$CRYSTAL_VERSION AS build
4+
WORKDIR /app
5+
6+
# Set the commit via a build arg
7+
ARG PLACE_COMMIT="DEV"
8+
# Set the platform version via a build arg
9+
ARG PLACE_VERSION="DEV"
10+
11+
# Create a non-privileged user, defaults are appuser:10001
12+
ARG IMAGE_UID="10001"
13+
ENV UID=$IMAGE_UID
14+
ENV USER=appuser
15+
16+
# See https://stackoverflow.com/a/55757473/12429735
17+
RUN adduser \
18+
--disabled-password \
19+
--gecos "" \
20+
--home "/nonexistent" \
21+
--shell "/sbin/nologin" \
22+
--no-create-home \
23+
--uid "${UID}" \
24+
"${USER}"
25+
26+
# Install package updates since image release
27+
RUN apk update && apk --no-cache --quiet upgrade
28+
29+
RUN update-ca-certificates
30+
31+
# Install shards for caching
32+
COPY shard.yml shard.yml
33+
COPY shard.override.yml shard.override.yml
34+
COPY shard.lock shard.lock
35+
36+
RUN shards install --production --ignore-crystal-version --skip-postinstall --skip-executables
37+
38+
# Add src
39+
COPY ./src /app/src
40+
41+
# Build application
42+
RUN UNAME_AT_COMPILE_TIME=true \
43+
PLACE_COMMIT=$PLACE_COMMIT \
44+
PLACE_VERSION=$PLACE_VERSION \
45+
shards build --production --error-trace --static
46+
47+
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
48+
49+
# Extract binary dependencies
50+
RUN mkdir -p /app/deps && for binary in /app/bin/*; do \
51+
{ ldd "$binary" 2>/dev/null || true; } | \
52+
tr -s '[:blank:]' '\n' | \
53+
grep '^/' | \
54+
xargs -I % sh -c 'mkdir -p $(dirname deps%); cp % deps%;' || true; \
55+
done
56+
57+
# Generate OpenAPI docs while we still have source code access
58+
RUN ./bin/placeos-auth --docs > openapi.yml
59+
60+
RUN git config --system http.sslCAInfo /etc/ssl/certs/ca-certificates.crt
61+
62+
# Build a minimal docker image
63+
FROM scratch
64+
WORKDIR /
65+
ENV PATH=$PATH:/
66+
67+
# Copy the user information over
68+
COPY --from=build etc/passwd /etc/passwd
69+
COPY --from=build /etc/group /etc/group
70+
71+
# These are required for communicating with external services
72+
COPY --from=build /etc/hosts /etc/hosts
73+
74+
# These provide certificate chain validation where communicating with external services over TLS
75+
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
76+
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
77+
78+
# This is required for Timezone support
79+
COPY --from=build /usr/share/zoneinfo/ /usr/share/zoneinfo/
80+
81+
# This is your application
82+
COPY --from=build /app/deps /
83+
COPY --from=build /app/bin /
84+
85+
# Copy the docs into the container, you can serve this file in your app
86+
COPY --from=build /app/openapi.yml /openapi.yml
87+
88+
# Use an unprivileged user.
89+
USER appuser:appuser
90+
91+
# Spider-gazelle has a built in helper for health checks
92+
HEALTHCHECK CMD ["/placeos-auth", "-c", "http://127.0.0.1:8080/auth/healthz"]
93+
94+
# Run the app binding on port 8080
95+
EXPOSE 8080
96+
ENTRYPOINT ["/placeos-auth"]
97+
CMD ["/placeos-auth", "-b", "0.0.0.0", "-p", "8080"]

docker-compose.override.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# LOCAL-ONLY (untracked): remap published test-DB ports off the host's
2+
# native postgres:5432 / redis:6379 so the isolated spec stack can start.
3+
# The `test` service still reaches these over the compose network by
4+
# hostname, so connectivity is unchanged.
5+
services:
6+
postgres:
7+
ports:
8+
- "15499:5432"
9+
redis:
10+
ports:
11+
- "16399:6379"

spec/controllers/oauth_provider_flows_spec.cr

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,35 @@ module PlaceOS::Auth
140140
cleanup_login.call(uid) if uid
141141
strat.try &.destroy
142142
end
143+
144+
it "recovers the strat id from the session when the IdP drops ?id= on the callback" do
145+
strat = google_strat.call
146+
uid = "google-noid-#{Random.rand(999999)}"
147+
email = "grace-#{Random.rand(999999)}@localhost"
148+
149+
WebMock.stub(:post, "https://accounts.google.com/token").to_return(
150+
status: 200, headers: json_headers,
151+
body: {access_token: "g-access", token_type: "Bearer", expires_in: 3600}.to_json,
152+
)
153+
WebMock.stub(:get, "https://openidconnect.googleapis.com/v1/userinfo").to_return(
154+
status: 200, headers: json_headers,
155+
body: {sub: uid, email: email, name: "Grace Hopper", given_name: "Grace", family_name: "Hopper"}.to_json,
156+
)
157+
158+
k = kickoff.call(strat.id.as(String))
159+
# The callback URL deliberately OMITS `?id=` (some IdPs don't round-trip
160+
# it). The id must be recovered from the session state stashed at kickoff
161+
# — otherwise this 401s "oauth state mismatch".
162+
result = client.get(
163+
"/auth/oauth2/callback?code=g-code&state=#{k[:state]}",
164+
headers: HTTP::Headers{"Host" => "localhost", "Cookie" => k[:cookie]},
165+
)
166+
result.status_code.should eq 303
167+
::PlaceOS::Model::UserAuthLookup.find?("auth-#{authority_id.call}-oauth2-#{uid}").should_not be_nil
168+
ensure
169+
cleanup_login.call(uid) if uid
170+
strat.try &.destroy
171+
end
143172
end
144173

145174
# ---- Azure AD / Entra ID -----------------------------------------

spec/controllers/saml_callbacks_spec.cr

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,39 @@ module PlaceOS::Auth
109109
result.status_code.should eq 404
110110
end
111111
end
112+
113+
# The callback's session-state CSRF check is skipped for SAML — the
114+
# cross-site HTTP-POST binding drops the SameSite=Lax cookie so the stashed
115+
# state is unavailable, and the signed assertion authenticates instead
116+
# (PPT-2536). It stays fully enforced for the OAuth2 path.
117+
describe "callback state check" do
118+
it "does NOT reject a SAML callback as an 'oauth state mismatch'" do
119+
strat = create_saml_strat.call
120+
# No prior kickoff => no stored session state. An OAuth2 callback would
121+
# 401 "oauth state mismatch" here; a SAML callback must get PAST that
122+
# check (and instead fail later at signed-assertion validation).
123+
result = client.post(
124+
"/auth/saml/callback?id=#{URI.encode_www_form(strat.id.as(String))}",
125+
headers: HTTP::Headers{
126+
"Host" => "localhost",
127+
"Content-Type" => "application/x-www-form-urlencoded",
128+
},
129+
body: "SAMLResponse=#{URI.encode_www_form("not-a-real-assertion")}&RelayState=xyz",
130+
)
131+
result.body.should_not contain "oauth state mismatch"
132+
ensure
133+
strat.try &.destroy
134+
end
135+
136+
it "still enforces the session-state check for an OAuth2 callback" do
137+
# No stored state => the OAuth2 path rejects with a state mismatch.
138+
result = client.get(
139+
"/auth/oauth2/callback?id=whatever&state=whatever",
140+
headers: HTTP::Headers{"Host" => "localhost"},
141+
)
142+
result.status_code.should eq 401
143+
result.body.should contain "oauth state mismatch"
144+
end
145+
end
112146
end
113147
end

src/placeos-auth/controllers/provider_callbacks.cr

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,33 @@ module PlaceOS::Auth
9898
raise Error::NotFound.new("authority not found") if authority.nil?
9999

100100
expect_state, expect_provider, expect_id = consume_stored_state
101-
if expect_state.nil? || state != expect_state || expect_provider != provider || expect_id != (id || "")
102-
Log.warn { {action: "provider_callbacks.callback", message: "state mismatch", provider: provider} }
103-
raise Error::Unauthorized.new("oauth state mismatch")
101+
102+
# Recover the strategy id from the stored session state when the IdP
103+
# dropped the `?id=` query param on the callback redirect (some OAuth
104+
# providers don't round-trip query params on `redirect_uri`). The exact
105+
# id was stashed in `#initiate`, so use it rather than 401 — more precise
106+
# than the legacy Ruby guess (first strat / authority callback URI). A
107+
# present-but-mismatched id is left alone, so the state check below still
108+
# rejects it as CSRF.
109+
if (id.nil? || id.empty?) && (recovered_id = expect_id.presence)
110+
id = recovered_id
111+
end
112+
113+
# SAML (adfs) uses the HTTP-POST binding: the IdP auto-submits the signed
114+
# assertion cross-site to the ACS URL, so the browser withholds our
115+
# SameSite=Lax auth-flow cookie and the CSRF `state` stashed in
116+
# `#initiate` is unavailable here — and the IdP echoes the CSRF value
117+
# back as `RelayState`, not `state`, anyway. The legacy Ruby service
118+
# (omniauth-saml) never did session-state CSRF for SAML; the callback was
119+
# authenticated purely by the signed assertion (idp_cert /
120+
# idp_cert_fingerprint, want_assertions_signed). Mirror that: skip the
121+
# session-state check for SAML and let `engine.user` validate the
122+
# signature. The OAuth2 path (Lax cookie sent) keeps full validation.
123+
unless saml_provider?(provider)
124+
if expect_state.nil? || state != expect_state || expect_provider != provider || expect_id != (id || "")
125+
Log.warn { {action: "provider_callbacks.callback", message: "state mismatch", provider: provider} }
126+
raise Error::Unauthorized.new("oauth state mismatch")
127+
end
104128
end
105129

106130
redirect_uri = callback_uri(provider, id)
@@ -187,6 +211,15 @@ module PlaceOS::Auth
187211
authorize_uri.gsub("%3Fid%3D", "%2F").gsub("?id=", "/")
188212
end
189213

214+
# SAML callbacks arrive on the `adfs`/`saml` provider names registered by
215+
# `ExternalProviders`. They cannot participate in session-state CSRF (the
216+
# cross-site HTTP-POST binding drops the SameSite=Lax cookie) and are
217+
# authenticated by the signed assertion instead.
218+
private def saml_provider?(provider : String) : Bool
219+
provider == ExternalProviders::SAML_PROVIDER ||
220+
provider == ExternalProviders::SAML_PROVIDER_ALIAS
221+
end
222+
190223
private def consume_stored_state : Tuple(String?, String?, String?)
191224
raw = session.delete(SESSION_OAUTH_STATE)
192225
return {nil, nil, nil} unless raw.is_a?(String)

0 commit comments

Comments
 (0)