Skip to content

Commit 5dd7b9f

Browse files
authored
fix(openid-connect): handle temporarily_unavailable error redirects from the ID provider (#13825)
1 parent f17bcb2 commit 5dd7b9f

2 files changed

Lines changed: 529 additions & 18 deletions

File tree

apisix/plugins/openid-connect.lua

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ local plugin_name = "openid-connect"
4141
local STATE_MISMATCH_ERR =
4242
"state from argument does not match state restored from session"
4343

44+
-- prefix of the resty.openidc error returned when the redirect_uri is
45+
-- requested without an authorization response, e.g. an OAuth2 error
46+
-- redirect (RFC 6749 section 4.1.2.1) instead of a code
47+
local UNHANDLED_REDIRECT_URI_ERR = "unhandled request to the redirect_uri"
48+
49+
-- max consecutive restarts of the authentication flow from failed
50+
-- authorization callbacks
51+
local MAX_AUTH_FLOW_RESTARTS = 3
52+
4453

4554
-- Session config is passed as-is to resty.session.start(); the only
4655
-- translation is the legacy session.cookie.lifetime alias from the
@@ -1294,34 +1303,88 @@ function _M.rewrite(plugin_conf, ctx)
12941303
build_session_opts(conf.session))
12951304

12961305
if err then
1297-
if session then
1298-
session:close()
1299-
end
13001306
if err == "unauthorized request" then
1307+
if session then
1308+
session:close()
1309+
end
13011310
if conf.unauth_action == "pass" then
13021311
return nil
13031312
end
13041313
return 401
13051314
end
13061315

1307-
-- Stale authorization callback: the session holds no authorization
1308-
-- state for the state in the callback, e.g. an already completed
1309-
-- callback was replayed, or the state was pruned after too many
1310-
-- concurrent flows. (Concurrent logins in several tabs are handled
1311-
-- by resty.openidc itself since 1.9.0, which keeps one
1312-
-- authorization state per in-flight flow.) The client is a browser
1313-
-- mid-navigation, so instead of a dead-end 500, send it back to the
1314-
-- original URL that resty.openidc returns alongside the error: a
1315-
-- fresh flow starts from there and completes without any user
1316-
-- interaction while the ID provider still holds an SSO session.
1317-
if err == STATE_MISMATCH_ERR and target_url
1316+
-- Recoverable authorization-callback failures: a stale state
1317+
-- (replayed or pruned callback), or the ID provider redirecting
1318+
-- back with error=temporarily_unavailable, e.g. Keycloak after
1319+
-- its login session expired. The client is a browser
1320+
-- mid-navigation, so restart the authentication flow by sending
1321+
-- it back to the original URL instead of dead-ending with a 500.
1322+
-- Other OAuth2 error codes (access_denied, login_required, ...)
1323+
-- reflect a deliberate outcome and are not retried.
1324+
local restart_reason
1325+
local restart_url = target_url
1326+
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
1329+
restart_reason = "state mismatch (replayed or pruned callback)"
1330+
elseif session and core.string.has_prefix(err, UNHANDLED_REDIRECT_URI_ERR) then
1331+
local uri_args = ngx.req.get_uri_args()
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
1348+
restart_reason = "authorization callback reported a " ..
1349+
"temporarily unavailable identity provider" ..
1350+
(type(uri_args.error_description) == "string" and
1351+
(" (" .. uri_args.error_description .. ")") or "")
1352+
restart_url = authorization_state.original_url or target_url
1353+
end
1354+
end
1355+
1356+
if restart_reason and restart_url and session
13181357
and ngx.req.get_method() == "GET" then
1319-
core.log.warn("OIDC state mismatch (replayed or pruned ",
1320-
"callback), restarting the authentication flow")
1321-
core.response.set_header("Location", target_url)
1322-
return 302
1358+
-- bound the redirect loop in case the failure is not
1359+
-- transient; the counter is reset once a request
1360+
-- authenticates
1361+
local restarts = session:get("auth_flow_restarts") or 0
1362+
if restarts < MAX_AUTH_FLOW_RESTARTS then
1363+
session:set("auth_flow_restarts", restarts + 1)
1364+
local ok, save_err = session:save()
1365+
if not ok then
1366+
session:close()
1367+
core.log.error("OIDC authentication failed: ", err,
1368+
" (could not persist the restart ",
1369+
"counter: ", save_err, ")")
1370+
return 500
1371+
end
1372+
session:close()
1373+
core.log.warn("OIDC ", restart_reason,
1374+
", restarting the authentication flow")
1375+
core.response.set_header("Location", restart_url)
1376+
return 302
1377+
end
1378+
session:close()
1379+
core.log.error("OIDC authentication failed: ", err,
1380+
" (giving up after ", restarts,
1381+
" restarts of the authentication flow)")
1382+
return 500
13231383
end
13241384

1385+
if session then
1386+
session:close()
1387+
end
13251388
core.log.error("OIDC authentication failed: ", err)
13261389
return 500
13271390
end
@@ -1365,6 +1428,15 @@ function _M.rewrite(plugin_conf, ctx)
13651428
if enc_id_token and conf.set_raw_id_token_header then
13661429
core.request.set_header(ctx, "X-Raw-ID-Token", enc_id_token)
13671430
end
1431+
1432+
-- a successful authentication resets the restart budget
1433+
if session:get("auth_flow_restarts") then
1434+
session:set("auth_flow_restarts", nil)
1435+
local ok, save_err = session:save()
1436+
if not ok then
1437+
core.log.error("failed to save session: ", save_err)
1438+
end
1439+
end
13681440
end
13691441
end
13701442
if session then

0 commit comments

Comments
 (0)