Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/src/infrastructure/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,13 @@ async def oauth_google_callback(
}

redirect_to = str(state_data.redirect_to) if state_data.redirect_to else "/"
return RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND)
# The cookies must ride the returned response itself: FastAPI does not
# merge headers set on the injected ``response`` into a directly-returned
# Response, so relying on it silently drops the session on the browser
# (redirect) flow - only the json flow would get the cookies.
redirect = RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND)
crud_auth.sessions.set_session_cookies(redirect, session_id, csrf_token)
return redirect

except Exception as e:
logger.error(f"Error in Google OAuth callback: {str(e)}", exc_info=True)
Expand Down
48 changes: 48 additions & 0 deletions backend/tests/integration/auth/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,54 @@ async def test_oauth_callback_success_creates_user(client: AsyncClient):
mock_storage.delete.assert_awaited_once()


@pytest.mark.asyncio
async def test_oauth_callback_redirect_sets_session_cookies(client: AsyncClient):
"""The browser (redirect) callback flow must carry the session cookies on the 302.

FastAPI does not merge headers set on the injected ``response`` into a
directly-returned ``RedirectResponse``, so the cookies must be set on the
redirect itself. The json flow is covered by
``test_oauth_callback_success_creates_user``; this pins the redirect flow.
"""
valid_state = OAuthState(
state="redirect-state",
provider="google",
redirect_to="/docs",
code_verifier="test-code-verifier",
)
mock_storage = MagicMock()
mock_storage.get = AsyncMock(return_value=valid_state)
mock_storage.delete = AsyncMock(return_value=None)

mock_provider = MagicMock()
mock_provider.exchange_code = AsyncMock(return_value={"access_token": "tok"})
mock_provider.get_user_info = AsyncMock(return_value={})
mock_provider.process_user_info = AsyncMock(
return_value=OAuthUserInfo(
provider="google",
provider_user_id="google-uid-redirect",
email="redirect_flow@example.com",
email_verified=True,
name="Redirect Flow",
)
)

with (
patch(f"{ROUTES}.oauth_state_storage", mock_storage),
patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}),
):
response = await client.get(
"/api/v1/auth/oauth/callback/google",
params={"code": "test-code", "state": "redirect-state"},
)

assert response.status_code == 302
assert response.headers["location"] == "/docs"
set_cookie = response.headers.get_list("set-cookie")
assert any(c.startswith("session_id=") for c in set_cookie), set_cookie
assert any(c.startswith("csrf_token=") for c in set_cookie), set_cookie


@pytest.mark.asyncio
async def test_check_auth_user_not_found(client: AsyncClient):
"""A resolved principal whose user row is missing reports authenticated=false."""
Expand Down
Loading