diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 59b6f31..2ecb5de 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,5 +1,6 @@ import logging import secrets +from urllib.parse import quote from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request @@ -432,7 +433,40 @@ async def sso_login(request: Request): ) redirect_uri = f"{cfg.APP_URL}/api/auth/sso/callback" - return await oauth.oidc.authorize_redirect(request, redirect_uri) + + # Where to come back to, and whether this attempt is allowed to show a + # login screen. Both live in the session rather than the URL: the callback + # needs them and neither is the provider's business. + # + # `prompt=none` is the whole of "sign me in if you already know me". + # Somebody who signed in at the provider for the other app is signed in + # here too, without a screen, without a click — and somebody who is not + # gets a quiet refusal we can act on rather than a login page they did not + # ask for. + request.session["sso_next"] = _safe_next(request.query_params.get("next")) + silent = request.query_params.get("prompt") == "none" + request.session["sso_silent"] = silent + extra = {"prompt": "none"} if silent else {} + return await oauth.oidc.authorize_redirect(request, redirect_uri, **extra) + + +#: The provider's way of saying "nobody is signed in here" to a silent +#: attempt. None of the three is a failure; they are the answer. +QUIET_REFUSALS = {"login_required", "interaction_required", "consent_required", + "account_selection_required"} + + +def _safe_next(raw: str | None) -> str: + """A path within this app, or the front door. + + Anything else — a scheme, a host, a protocol-relative `//evil` — is an + open redirect, which is exactly the thing a sign-in round trip must not + become. + """ + path = (raw or "").strip() + if not path.startswith("/") or path.startswith("//"): + return "/" + return path[:500] @router.get("/sso/callback") @@ -455,9 +489,25 @@ async def sso_callback(request: Request, db: Session = Depends(get_db)): client_kwargs={"scope": cfg.OIDC_SCOPES}, ) + silent = bool(request.session.pop("sso_silent", False)) + landing = _safe_next(request.session.pop("sso_next", None)) + + # A silent attempt that finds nobody signed in is not a failure. The + # provider says so in the query string, and the visitor should land on the + # page they asked for with no message and no sign of having been anywhere. + refusal = request.query_params.get("error") + if refusal: + if silent and refusal in QUIET_REFUSALS: + return RedirectResponse(url=f"{cfg.APP_URL}{landing}") + return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed") + try: token = await oauth.oidc.authorize_access_token(request) except Exception: + if silent: + # Anything at all going wrong on an attempt nobody asked for is + # still not something to interrupt them with. + return RedirectResponse(url=f"{cfg.APP_URL}{landing}") return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed") userinfo = token.get("userinfo") or {} @@ -521,4 +571,7 @@ async def sso_callback(request: Request, db: Session = Depends(get_db)): if code is None: logger.error("SSO succeeded but the exchange store is unreachable") return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed") - return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?code={code}") + # Back to whatever was being opened when the round trip started, so a link + # to one article lands on that article rather than the dashboard. + where = f"&next={quote(landing, safe='')}" if landing and landing != "/" else "" + return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?code={code}{where}") diff --git a/backend/tests/test_sso_hardening.py b/backend/tests/test_sso_hardening.py index ad60f36..82483f3 100644 --- a/backend/tests/test_sso_hardening.py +++ b/backend/tests/test_sso_hardening.py @@ -112,3 +112,33 @@ class OneTimeCodeTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class SilentSignInTests(unittest.TestCase): + """Signed in at the provider for the other app means signed in here. + + `prompt=none` asks "do you already know this person?". If the provider + does, the round trip completes with no screen and no click. If it does + not, it refuses in the query string — and that refusal is the answer, not + a failure to report. + """ + + def test_a_quiet_refusal_is_not_an_error(self): + from app.routers.auth import QUIET_REFUSALS + for said in ("login_required", "interaction_required", + "consent_required", "account_selection_required"): + self.assertIn(said, QUIET_REFUSALS) + # A real failure still is one. + self.assertNotIn("invalid_client", QUIET_REFUSALS) + self.assertNotIn("server_error", QUIET_REFUSALS) + + def test_the_return_path_cannot_leave_the_site(self): + """A sign-in round trip is exactly where an open redirect would live.""" + from app.routers.auth import _safe_next + self.assertEqual(_safe_next("/articles/95?section=abc"), "/articles/95?section=abc") + self.assertEqual(_safe_next("//evil.example"), "/") + self.assertEqual(_safe_next("https://evil.example/x"), "/") + self.assertEqual(_safe_next("javascript:alert(1)"), "/") + self.assertEqual(_safe_next(None), "/") + self.assertEqual(_safe_next(""), "/") + self.assertEqual(len(_safe_next("/" + "a" * 900)), 500) diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 32023ab..d04415d 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,6 +1,7 @@ import { createContext, useContext, useState, useEffect } from 'react' import api from '../api/client' import { setToken } from '../utils/token' +import { markSignedOut, shouldTrySilently, trySilently } from '../utils/silentSso' const AuthContext = createContext(null) @@ -16,7 +17,19 @@ export function AuthProvider({ children }) { .catch(() => { if (localStorage.getItem('token') === token) setToken(null) }) .finally(() => setLoading(false)) } else { - setLoading(false) + // Nobody signed in here — but they may be signed in at the provider for + // the other app, in which case they should not be shown a door. One + // silent attempt, then the page as normal. `loading` stays true while + // the browser is on its way out, so nothing paints and swaps. + api.get('/auth/sso/config') + .then(res => { + if (shouldTrySilently({ hasToken: false, ssoEnabled: res.data?.sso_enabled === true })) { + trySilently() + return + } + setLoading(false) + }) + .catch(() => setLoading(false)) } }, []) @@ -38,6 +51,10 @@ export function AuthProvider({ children }) { const logout = () => { setToken(null) setUser(null) + // Somebody who signs out has said they want to be signed out. Without + // this the next page load would sign them straight back in, which is not + // a bug anybody would report — it just looks like the button is broken. + markSignedOut() } return ( diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 73542db..254ad97 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import api from '../api/client' +import { clearSignedOut } from '../utils/silentSso' import { useAuth } from '../context/AuthContext' import useMediaQuery from '../hooks/useMediaQuery' import Navbar from '../components/Navbar' @@ -292,7 +293,7 @@ function AuthModal({ onClose }) { {/* The provider, first, and on an SSO-only site the only thing here. */} {ssoEnabled === true && ( <> - Sign in with {providerName} @@ -588,7 +589,7 @@ export default function LandingPage() { return () => { live = false } }, []) const signIn = () => { - if (ssoOnly) window.location.href = '/api/auth/sso/login' + if (ssoOnly) { clearSignedOut(); window.location.href = '/api/auth/sso/login' } else setAuthOpen(true) } diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 245402a..197f373 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useNavigate, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' +import { clearSignedOut } from '../utils/silentSso' /** * Signing in. @@ -61,7 +62,7 @@ export default function LoginPage() { {ssoConfig?.sso_enabled && ( <> - + Sign in with {ssoConfig.provider_name} {!ssoConfig.sso_only &&