Skip to content

Commit d57df06

Browse files
committed
fix(openid-connect): validate callback state before restarting the flow
resty.openidc reports an unhandled request to the redirect_uri before it validates the callback state, so match the state against the session's in-flight flows here. A callback whose state was never issued no longer restarts the flow, and the restart redirects to the original URL of the flow the state belongs to rather than the session-level fallback.
1 parent 74a0571 commit d57df06

2 files changed

Lines changed: 81 additions & 6 deletions

File tree

apisix/plugins/openid-connect.lua

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,19 +1322,38 @@ function _M.rewrite(plugin_conf, ctx)
13221322
-- Other OAuth2 error codes (access_denied, login_required, ...)
13231323
-- reflect a deliberate outcome and are not retried.
13241324
local restart_reason
1325+
local restart_url = target_url
13251326
if err == STATE_MISMATCH_ERR then
1327+
-- state already matched by resty.openidc; no in-flight flow
1328+
-- for it, so its session-level original_url is all we have
13261329
restart_reason = "state mismatch (replayed or pruned callback)"
1327-
elseif core.string.has_prefix(err, UNHANDLED_REDIRECT_URI_ERR) then
1330+
elseif session and core.string.has_prefix(err, UNHANDLED_REDIRECT_URI_ERR) then
13281331
local uri_args = ngx.req.get_uri_args()
1329-
if uri_args.error == "temporarily_unavailable" then
1332+
-- resty.openidc bails on this path before validating state, so
1333+
-- match it here as it would: rejects a forged callback, and
1334+
-- recovers the original_url of the flow it belongs to (each
1335+
-- in-flight flow keeps its own since 1.9.0)
1336+
local authorization_state
1337+
if uri_args.error == "temporarily_unavailable" and uri_args.state then
1338+
local states = session:get("authorization_states")
1339+
authorization_state = states and states[uri_args.state]
1340+
if not authorization_state
1341+
and uri_args.state == session:get("state") then
1342+
authorization_state = {
1343+
original_url = session:get("original_url")
1344+
}
1345+
end
1346+
end
1347+
if authorization_state then
13301348
restart_reason = "authorization callback reported a " ..
13311349
"temporarily unavailable identity provider" ..
13321350
(type(uri_args.error_description) == "string" and
13331351
(" (" .. uri_args.error_description .. ")") or "")
1352+
restart_url = authorization_state.original_url or target_url
13341353
end
13351354
end
13361355

1337-
if restart_reason and target_url and session
1356+
if restart_reason and restart_url and session
13381357
and ngx.req.get_method() == "GET" then
13391358
-- bound the redirect loop in case the failure is not
13401359
-- transient; the counter is reset once a request
@@ -1349,7 +1368,7 @@ function _M.rewrite(plugin_conf, ctx)
13491368
session:close()
13501369
core.log.warn("OIDC ", restart_reason,
13511370
", restarting the authentication flow")
1352-
core.response.set_header("Location", target_url)
1371+
core.response.set_header("Location", restart_url)
13531372
return 302
13541373
end
13551374
session:close()

t/plugin/openid-connect12.t

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,10 +311,12 @@ restarting the authentication flow
311311
statuses[6] = res_f.status
312312
jar = cookie_of(res_f) or jar
313313
314-
-- ... so a later transient callback error is retried again
314+
-- ... so a later transient callback error is retried again. The
315+
-- first flow's state is still in-flight (only the completed flow's
316+
-- state was consumed), so the callback passes the state check.
315317
local res_g = http.new():request_uri(
316318
base .. "/oidc12/callback?error=temporarily_unavailable" ..
317-
"&error_description=authentication_expired&state=deadbeef", {
319+
"&error_description=authentication_expired&state=" .. state, {
318320
headers = {Cookie = jar}
319321
})
320322
statuses[7] = res_g.status
@@ -323,3 +325,57 @@ restarting the authentication flow
323325
}
324326
--- response_body
325327
302 302 302 500 302 404 302
328+
329+
330+
331+
=== TEST 8: a temporarily unavailable callback whose state was never issued is not retried
332+
--- config
333+
location /t {
334+
content_by_lua_block {
335+
local http = require "resty.http"
336+
local base = "http://127.0.0.1:" .. ngx.var.server_port
337+
338+
local function cookie_of(res)
339+
local c = res.headers["Set-Cookie"]
340+
if type(c) == "table" then
341+
c = table.concat(c, "; ")
342+
end
343+
return c and c:match("^([^;]+)")
344+
end
345+
346+
-- start a login flow so the session holds a valid in-flight state
347+
local res_a = http.new():request_uri(base .. "/oidc12/page")
348+
local state = res_a.headers["Location"]:match("state=([^&]+)")
349+
local jar = cookie_of(res_a)
350+
351+
-- a forged callback (e.g. a cross-site request) carries a state the
352+
-- session never issued, or none at all: it must not restart the
353+
-- flow, so it cannot spend the restart budget of the real flow
354+
local statuses = {}
355+
local res_b = http.new():request_uri(
356+
base .. "/oidc12/callback?error=temporarily_unavailable" ..
357+
"&error_description=authentication_expired&state=deadbeef", {
358+
headers = {Cookie = jar}
359+
})
360+
statuses[1] = res_b.status
361+
362+
local res_c = http.new():request_uri(
363+
base .. "/oidc12/callback?error=temporarily_unavailable" ..
364+
"&error_description=authentication_expired", {
365+
headers = {Cookie = jar}
366+
})
367+
statuses[2] = res_c.status
368+
369+
-- the real flow's state is still accepted and still has its full
370+
-- budget, so it restarts
371+
local res_d = http.new():request_uri(
372+
base .. "/oidc12/callback?error=temporarily_unavailable" ..
373+
"&error_description=authentication_expired&state=" .. state, {
374+
headers = {Cookie = jar}
375+
})
376+
statuses[3] = res_d.status
377+
ngx.say(table.concat(statuses, " "))
378+
}
379+
}
380+
--- response_body
381+
500 500 302

0 commit comments

Comments
 (0)