Skip to content

Commit 375ea36

Browse files
fix(rauthy): detect drift when payload omits a field present on server (#140)
drift_field only iterated payload keys, so any caller that intentionally omitted a field (to clear it on the server) silently noop'd. Add a reverse pass that walks got's keys and flags any field present on the server but absent from the payload — Rauthy's PUT is a full-replacement so a follow-up put() clears the field cleanly. Also drop `challenges` from the outline preset. Outline 1.8.x sends authorize requests without code_challenge and Rauthy 400's 'code_challenge missing' on any client declaring challenges. The confidential client + client_secret carries authn weight; PKCE is redundant for this consumer. Bump 0.15.12 -> 0.15.13 + 1 new test for the reverse-direction drift.
1 parent 676b1c8 commit 375ea36

4 files changed

Lines changed: 59 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/assay/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "assay-lua"
3-
version = "0.15.12"
3+
version = "0.15.13"
44
categories = ["command-line-utilities", "development-tools::testing"]
55
edition = "2024"
66
homepage = "https://assay.rs"

crates/assay/stdlib/rauthy.lua

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ local function drift_field(payload, got)
4545
for k, v in pairs(payload) do
4646
if not field_eq(v, got[k]) then return k end
4747
end
48+
-- Reverse direction: a field present in `got` but absent from `payload`
49+
-- means the caller wants it removed. Without this, a preset shipped today
50+
-- with `challenges = {"S256"}` and later overridden to omit `challenges`
51+
-- would silently noop because the forward loop never visits the missing
52+
-- key. Rauthy's PUT is a full-replacement, so a follow-up put() correctly
53+
-- clears the field.
54+
for k, v in pairs(got) do
55+
if payload[k] == nil and v ~= nil then return k end
56+
end
4857
return nil
4958
end
5059

@@ -309,8 +318,10 @@ end
309318
-- Outline wiki. Confidential client (shared client_secret).
310319
-- * `id_token_alg = RS256` — Outline uses jose for JWT validation; RS256 is the
311320
-- widely-supported default. EdDSA is not in jose's default key-resolution path.
312-
-- * `challenges = [S256]` — Outline performs PKCE on browser flows by default
313-
-- and Rauthy requires the client to declare the challenge method.
321+
-- * No `challenges` field — Outline 1.8.x sends authorize requests without
322+
-- `code_challenge`, and Rauthy 400's "code_challenge missing" on any client
323+
-- that declares challenges. Confidential client + client_secret carries the
324+
-- authn weight; PKCE is redundant here.
314325
-- * Redirect URI is `/auth/oidc.callback` (Outline's hardcoded OIDC callback path).
315326
function M.client_presets.outline(opts)
316327
if not opts or not opts.host then
@@ -334,7 +345,6 @@ function M.client_presets.outline(opts)
334345
access_token_lifetime = 1800,
335346
scopes = { "openid", "email", "profile" },
336347
default_scopes = { "openid", "email", "profile" },
337-
challenges = { "S256" },
338348
force_mfa = false,
339349
}
340350
end

crates/assay/tests/stdlib_rauthy.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,47 @@ async fn test_reconcile_noop_when_no_drift() {
195195
run_lua(&script).await.unwrap();
196196
}
197197

198+
#[tokio::test]
199+
async fn test_reconcile_put_when_payload_omits_field_present_on_server() {
200+
// Server has `challenges: ["S256"]`; caller's payload omits it (meaning
201+
// "remove that field"). Reconcile should detect drift on `challenges`
202+
// and issue a PUT — not silently noop.
203+
let server = MockServer::start().await;
204+
Mock::given(method("GET"))
205+
.and(path("/clients/openbao"))
206+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
207+
"id": "openbao",
208+
"name": "OpenBao",
209+
"confidential": true,
210+
"challenges": ["S256"],
211+
"redirect_uris": ["https://o.example.com/cb"]
212+
})))
213+
.mount(&server)
214+
.await;
215+
Mock::given(method("PUT"))
216+
.and(path("/clients/openbao"))
217+
.respond_with(ResponseTemplate::new(200))
218+
.mount(&server)
219+
.await;
220+
221+
let script = format!(
222+
r#"
223+
local rauthy = require("assay.rauthy")
224+
local c = rauthy.client("{}", "{}")
225+
local r = c.clients:reconcile({{
226+
id = "openbao", name = "OpenBao",
227+
confidential = true,
228+
redirect_uris = {{ "https://o.example.com/cb" }},
229+
}})
230+
assert.eq(r.action, "put")
231+
assert.eq(r.drift_on, "challenges")
232+
"#,
233+
server.uri(),
234+
api_key_lua_literal()
235+
);
236+
run_lua(&script).await.unwrap();
237+
}
238+
198239
#[tokio::test]
199240
async fn test_reconcile_create_on_404() {
200241
let server = MockServer::start().await;
@@ -383,7 +424,9 @@ async fn test_client_preset_outline() {
383424
assert.eq(p.confidential, true)
384425
assert.eq(p.id_token_alg, "RS256")
385426
assert.eq(p.access_token_alg, "RS256")
386-
assert.eq(p.challenges[1], "S256")
427+
-- Outline 1.8.x sends authorize without code_challenge; the preset
428+
-- intentionally omits `challenges` so Rauthy doesn't enforce PKCE.
429+
assert.eq(p.challenges, nil)
387430
assert.eq(p.redirect_uris[1], "https://wiki.example.com/auth/oidc.callback")
388431
assert.eq(p.scopes[1], "openid")
389432
assert.eq(p.scopes[2], "email")

0 commit comments

Comments
 (0)