diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index e1bad3a..0b1ce3b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -512,8 +512,16 @@ def sso_config(): @router.get("/sso/login") -def sso_login(request: Request): - """Redirect user to the OIDC provider for login.""" +async def sso_login(request: Request): + """Redirect user to the OIDC provider for login. + + `async`, and the redirect awaited. Authlib's Starlette client is the async + one — `authorize_redirect` hands back a coroutine — so a sync endpoint + returned that coroutine to FastAPI, which tried to serialise it as a + response body and answered 500: "'coroutine' object is not iterable". The + button had never worked; nothing found it because nothing had SSO + configured to click it with. + """ from authlib.integrations.starlette_client import OAuth from starlette.responses import RedirectResponse from app.config import settings as cfg @@ -531,7 +539,7 @@ def sso_login(request: Request): ) redirect_uri = f"{cfg.APP_URL}/api/auth/sso/callback" - return oauth.oidc.authorize_redirect(request, redirect_uri) + return await oauth.oidc.authorize_redirect(request, redirect_uri) @router.get("/sso/callback") diff --git a/backend/tests/test_sso_roles.py b/backend/tests/test_sso_roles.py index 0b0f415..385db17 100644 --- a/backend/tests/test_sso_roles.py +++ b/backend/tests/test_sso_roles.py @@ -94,3 +94,23 @@ class RoleSyncTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class SsoLoginShapeTests(unittest.TestCase): + """The login redirect has to be awaited, not returned. + + Authlib's Starlette client is the async one: `authorize_redirect` hands + back a coroutine. A sync endpoint returned that coroutine to FastAPI, + which tried to serialise it and answered 500 — so the SSO button 500'd the + first time anybody had a provider configured to click it with. A cheap + shape check, because the alternative is standing up an OIDC provider in a + unit test. + """ + + def test_the_redirect_endpoint_is_a_coroutine_function(self): + import inspect + from app.routers import auth + self.assertTrue(inspect.iscoroutinefunction(auth.sso_login)) + self.assertTrue(inspect.iscoroutinefunction(auth.sso_callback)) + source = inspect.getsource(auth.sso_login) + self.assertIn("await oauth.oidc.authorize_redirect", source)