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",
+8 -3
View File
@@ -5,10 +5,15 @@ import react from "@vitejs/plugin-react";
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
// Docker port publish may not answer — that surfaces as proxy connection failures.
const target = "http://127.0.0.1:8080";
// changeOrigin:false keeps the original `Host: localhost:5173` header when proxying to the backend
// (the TCP target is still 127.0.0.1:8080 — changeOrigin only rewrites the header). The backend uses
// that header to send the OAuth redirect_uri back to :5173, so a Google login started on the Vite dev
// server returns to :5173 instead of the fixed :8080 callback. (Both are registered in the console.)
const proxyOpts = { target, changeOrigin: false };
const proxy = {
"/api": target,
"/auth": target,
"/healthz": target,
"/api": proxyOpts,
"/auth": proxyOpts,
"/healthz": proxyOpts,
};
export default defineConfig({