fix(auth): fall back to session-based auth in _load_user (#14569)

## Summary

Closes #13663.

OAuth / OIDC callbacks call `login_user(user)` which writes `_user_id`
into the session cookie, but `_load_user()` in `api/apps/__init__.py`
only ever looked at the `Authorization` header. The SPA's response
interceptor wipes the Authorization value from `localStorage` on the
first 401 it sees — meaning that during the post-redirect window after
an OAuth login, a single transient 401 sends every subsequent request
back to the login page even though `login_user()` had already
established a perfectly good server-side session.

The reporter's analysis traces this all the way through the redirect →
`navigate('/')` → first request → empty header → 401 → `removeAll()` →
infinite-redirect-to-login chain.

## What changed

- New `_load_user_from_session()` helper that reads
`session["_user_id"]`, looks up the user in `UserService` (with the same
`StatusEnum.VALID` and `access_token` checks already used elsewhere),
and assigns `g.user`.
- Every `return None` path in `_load_user()` now routes through that
helper before giving up:
  - missing `Authorization` header
  - malformed `bearer ` prefix
  - empty / too-short JWT payload
  - JWT signature failure
  - JWT-resolved user not found / has no `access_token`
  - `APIToken.query()` fallback exhausted

The JWT and API-token paths still take precedence — the session is only
consulted when those can't authenticate the request. So existing
local-login and SDK callers see no behaviour change; only OAuth / OIDC
users that hit the original race now stay logged in.

The Bearer-prefix issue called out in #13663 (lines 103-110) is already
handled in the current code, so this PR only addresses the second half
of the report.

## Test plan

- [ ] Configure OIDC under `oauth` in `service_conf.yaml`
- [ ] Click the OIDC login button, complete auth at the IdP
- [ ] Confirm that navigating between pages no longer bounces back to
`/login`
- [ ] Confirm local email/password login still issues + accepts JWTs
- [ ] Confirm SDK/API key callers still authenticate via `Authorization:
Bearer <api-token>`

---------

Co-authored-by: Kevin Hu <kevinhu.sh@gmail.com>
This commit is contained in:
Mehmet Karakose
2026-05-11 04:59:52 +03:00
committed by GitHub
parent 6cb4bc2947
commit 7ec87f7cb7
2 changed files with 141 additions and 26 deletions

View File

@@ -175,6 +175,96 @@ def test_load_user_api_token_fallback_and_fallback_exception(monkeypatch, caplog
assert "api token fallback failed" in caplog.text
@pytest.mark.p2
def test_load_user_session_fallback(monkeypatch, caplog):
quart_app, apps_module = _load_apps_module(monkeypatch)
valid_token = "a" * 32
valid_user = SimpleNamespace(id="user-1", email="oidc@example.com", access_token=valid_token)
invalid_token_user = SimpleNamespace(id="user-1", email="oidc@example.com", access_token="INVALID_deadbeef")
short_token_user = SimpleNamespace(id="user-1", email="oidc@example.com", access_token="too-short")
async def _case():
# No Authorization header but a valid session: helper resolves the user.
async with quart_app.test_request_context("/"):
from quart import session
session["_user_id"] = "user-1"
monkeypatch.setattr(apps_module.UserService, "query", lambda **_kw: [valid_user])
assert apps_module._load_user() is valid_user
# Malformed bearer header still falls back to session.
async with quart_app.test_request_context("/", headers={"Authorization": "Bearer"}):
from quart import session
session["_user_id"] = "user-1"
monkeypatch.setattr(apps_module.UserService, "query", lambda **_kw: [valid_user])
assert apps_module._load_user() is valid_user
# Logout-revoked tokens (INVALID_ prefix) are rejected even with a session.
async with quart_app.test_request_context("/"):
from quart import session
session["_user_id"] = "user-1"
monkeypatch.setattr(apps_module.UserService, "query", lambda **_kw: [invalid_token_user])
assert apps_module._load_user() is None
# Short tokens are rejected (matches the JWT-path length floor).
async with quart_app.test_request_context("/"):
from quart import session
session["_user_id"] = "user-1"
monkeypatch.setattr(apps_module.UserService, "query", lambda **_kw: [short_token_user])
assert apps_module._load_user() is None
# No session and no header → still None.
async with quart_app.test_request_context("/"):
assert apps_module._load_user() is None
# Database errors during the session lookup are swallowed and logged.
async with quart_app.test_request_context("/"):
from quart import session
session["_user_id"] = "user-1"
def _raise(**_kw):
raise RuntimeError("db down")
monkeypatch.setattr(apps_module.UserService, "query", _raise)
with caplog.at_level(logging.ERROR):
assert apps_module._load_user() is None
_run(_case())
assert "load_user from session failed" in caplog.text
@pytest.mark.p2
def test_load_user_session_fallback_after_token_paths_fail(monkeypatch):
"""JWT-decode failures and API-token exhaustion must still fall through
to the session and return the user, not None."""
quart_app, apps_module = _load_apps_module(monkeypatch)
valid_token = "b" * 32
valid_user = SimpleNamespace(id="user-1", email="oidc@example.com", access_token=valid_token)
def _raise_decode(_self, _auth):
raise RuntimeError("jwt decode boom")
monkeypatch.setattr(apps_module.Serializer, "loads", _raise_decode)
monkeypatch.setattr(apps_module.APIToken, "query", lambda **_kw: [])
async def _case():
# JWT decode fails AND API-token query returns nothing → session wins.
async with quart_app.test_request_context("/", headers={"Authorization": "Bearer junk"}):
from quart import session
session["_user_id"] = "user-1"
monkeypatch.setattr(apps_module.UserService, "query", lambda **_kw: [valid_user])
assert apps_module._load_user() is valid_user
_run(_case())
@pytest.mark.p2
def test_login_required_timing_and_login_user_inactive(monkeypatch, caplog):
quart_app, apps_module = _load_apps_module(monkeypatch)
@@ -227,6 +317,7 @@ def test_logout_user_not_found_and_unauthorized_handlers(monkeypatch):
assert "Not Found:" in payload["message"]
async with quart_app.test_request_context("/protected"):
@apps_module.login_required
async def _protected():
return {"ok": True}