feat(dev): OAuth redirect returns to the origin that started it

A Google login started on the Vite dev server (:5173) always came back to the
fixed :8080 callback, because login/link/upgrade passed the static
settings.oauth_redirect_url. Derive the redirect_uri from the request origin
instead, but only in local dev and only for a known dev-host allowlist —
production still uses the fixed configured URL and never trusts the Host header
(host-injection guard). Set the Vite proxy to changeOrigin:false so it forwards
the real Host:localhost:5173 (it was rewriting it to the 127.0.0.1:8080 target).
Both callbacks must be registered in the Google console.
This commit is contained in:
2026-07-23 00:48:20 +02:00
parent b761a448a8
commit b4d2ad75e0
2 changed files with 33 additions and 6 deletions
+25 -3
View File
@@ -246,6 +246,28 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
return inv
# Local-dev origins allowed to drive the OAuth redirect_uri from the request Host. The Vite dev
# server (:5173) proxies /auth to the backend (:8080) preserving the Host header, and the session
# cookie is port-agnostic — so a flow started on :5173 can come back to :5173 instead of always
# landing on the fixed :8080 callback. Both must also be registered in the Google console.
_DEV_OAUTH_HOSTS = frozenset(
{"localhost:5173", "localhost:8080", "127.0.0.1:5173", "127.0.0.1:8080"}
)
def _oauth_redirect_uri(request: Request) -> str:
"""The redirect_uri handed to Google. Production always uses the fixed configured URL and never
trusts the request Host (a forged Host header must not be able to redirect the OAuth code). In
local dev we honor the request's own origin when it is one of the known dev hosts, so a login
started on the Vite server (:5173) returns to :5173 and one on :8080 returns to :8080."""
if settings.session_https_only: # https redirect URL == real deployment
return settings.oauth_redirect_url
host = (request.headers.get("host") or "").lower()
if host in _DEV_OAUTH_HOSTS:
return f"http://{host}/auth/callback"
return settings.oauth_redirect_url
@router.get("/login")
async def login(request: Request, db: Session = Depends(get_db)):
# access_type=offline ensures a refresh_token on first authorization. We avoid
@@ -258,7 +280,7 @@ async def login(request: Request, db: Session = Depends(get_db)):
return RedirectResponse(url="/")
return await client.authorize_redirect(
request,
settings.oauth_redirect_url,
_oauth_redirect_uri(request),
access_type="offline",
prompt="select_account",
include_granted_scopes="true",
@@ -1003,7 +1025,7 @@ async def link_google(
request.session["oauth_link_explicit"] = True
return await client.authorize_redirect(
request,
settings.oauth_redirect_url,
_oauth_redirect_uri(request),
access_type="offline",
prompt="select_account",
include_granted_scopes="true",
@@ -1068,7 +1090,7 @@ async def upgrade(
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await client.authorize_redirect(
request,
settings.oauth_redirect_url,
_oauth_redirect_uri(request),
access_type="offline",
prompt="consent",
include_granted_scopes="true",