From e64a60c42b3ac236f54385ec3460d89bab60d697 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 2 Sep 2026 10:52:25 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20connexion=20OAuth=20Gitea=20=E2=80=94=20?= =?UTF-8?q?redirect=20URI=20invalide=20(erreur=20'Unregistered=20Redirect?= =?UTF-8?q?=20URI')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le /auth/login construisait le redirect_uri avec le schéma http:// en dur et dupliquait l'expression dans login/callback : toute URL d'accès non enregistrée (https, reverse proxy, hostname, port différent) était rejetée par Gitea avec 'Unregistered Redirect URI' — les 2 liens 'Connect Gitea' et 'Register with Gitea' de Workspaces → Gitea Projects étaient touchés. - nouveau helper get_redirect_uri(request) : override explicite OAUTH_REDIRECT_URI (si défini), sinon schéma depuis X-Forwarded-Proto (fallback request scheme) + hôte depuis X-Forwarded-Host (fallback Host) - redirect_uri stocké en session à l'authorize et réutilisé tel quel dans l'échange de code (plus de dérive entre les deux étapes) - même correctif dans GitHubProvider.exchange_code (ignorait le paramètre) - config : oauth_redirect_uri par défaut vide (dynamique) au lieu de localhost:8080 en dur - .env.example documente OAUTH_REDIRECT_URI - 4 tests de régression (host header, X-Forwarded-Proto/Host, override env, URL d'authorize) — 178/179 OK, l'échec restant (test_views_calendar) est pré-existant et dépend de la date --- .env.example | 7 ++++++ app/auth/providers.py | 2 +- app/config.py | 5 +++-- app/routers/auth.py | 35 ++++++++++++++++++++++------- tests/test_app.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 929daf4..d9c3124 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,13 @@ GITEA_WEBHOOK_SECRET= GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET= +# ── OAuth2 ── +# Laisser VIDE = redirect URI dynamique (dérivée du Host/X-Forwarded-* de la requête). +# Ne définir QUE si on veut forcer une URI exacte — elle DOIT être enregistrée +# dans l'application OAuth2 côté Gitea/GitHub (Settings → Applications). +# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback +OAUTH_REDIRECT_URI= + # ── App ── APP_SECRET_KEY=change-me-to-random APP_HOST=0.0.0.0 diff --git a/app/auth/providers.py b/app/auth/providers.py index 2a1ed04..8c2b123 100644 --- a/app/auth/providers.py +++ b/app/auth/providers.py @@ -165,7 +165,7 @@ class GitHubProvider(OAuthProvider): "client_id": self.client_id, "client_secret": self.client_secret, "code": code, - "redirect_uri": self.redirect_uri, + "redirect_uri": redirect_uri or self.redirect_uri, }, headers={"Accept": "application/json"}, ) diff --git a/app/config.py b/app/config.py index 0096d63..9f84b5b 100644 --- a/app/config.py +++ b/app/config.py @@ -24,8 +24,9 @@ class Settings(BaseSettings): # Standalone mode standalone: bool = False # FLOWDECK_STANDALONE=true in .env - # OAuth2 - oauth_redirect_uri: str = "http://localhost:8080/auth/callback" + # OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*), + # set this ONLY to pin an exact URI (must be registered in Gitea/GitHub) + oauth_redirect_uri: str = "" # Webhook webhook_base_url: str = "http://localhost:8080" diff --git a/app/routers/auth.py b/app/routers/auth.py index de9ffcd..3455bc6 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -13,6 +13,24 @@ from app.config import settings logger = logging.getLogger(__name__) router = APIRouter(tags=["auth"], prefix="/auth") + +def get_redirect_uri(request: Request) -> str: + """OAuth redirect URI for this request. + + Explicit `OAUTH_REDIRECT_URI` env override wins (must be registered in the + provider's OAuth app). Otherwise it is derived from the request so it always + matches the URL the user actually used: scheme from `X-Forwarded-Proto` + (reverse proxies) falling back to the request scheme, host from + `X-Forwarded-Host` falling back to the `Host` header. + """ + if settings.oauth_redirect_uri: + return settings.oauth_redirect_uri + proto = request.headers.get("x-forwarded-proto", "") + scheme = proto.split(",")[0].strip() or request.url.scheme or "http" + fwd_host = request.headers.get("x-forwarded-host", "") + host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080") + return f"{scheme}://{host}/auth/callback" + LOCAL_LOGIN_HTML = """ @@ -144,10 +162,11 @@ async def login(request: Request, provider: str = Query("gitea")): # Encode auth mode in state to survive session loss during OAuth redirect signed_state = f"{state}:{mode}" if mode else state request.session["oauth_mode"] = mode - # Dynamic redirect URI based on incoming Host header - host = request.headers.get("host", "localhost:8080") - dynamic_redirect_uri = f"http://{host}/auth/callback" - auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link")) + # Redirect URI derived from the incoming request (scheme-aware); stored in + # session so the callback reuses the EXACT same URI for token exchange + redirect_uri = get_redirect_uri(request) + request.session["oauth_redirect_uri"] = redirect_uri + auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=redirect_uri, force_login=(mode == "link")) return RedirectResponse(url=auth_url, status_code=302) @@ -282,10 +301,10 @@ async def callback( if not oauth_provider: return HTMLResponse(f"

Unknown provider: {provider_name}

", status_code=400) - # Exchange code for token — use dynamic redirect URI matching the authorize step - host = request.headers.get("host", "localhost:8080") - dynamic_redirect_uri = f"http://{host}/auth/callback" - token_data = await oauth_provider.exchange_code(code, redirect_uri=dynamic_redirect_uri) + # Exchange code for token — reuse the redirect URI from the authorize step + # (stored in session), falling back to deriving it from this request + redirect_uri = request.session.get("oauth_redirect_uri") or get_redirect_uri(request) + token_data = await oauth_provider.exchange_code(code, redirect_uri=redirect_uri) if not token_data: return HTMLResponse("

Token exchange failed

", status_code=400) diff --git a/tests/test_app.py b/tests/test_app.py index 7b0e4aa..70bcb94 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1691,6 +1691,57 @@ def test_oauth_login_with_mode_link_and_provider_github(client): assert "github" in resp.text.lower() or "not configured" in resp.text.lower() +def _oauth_request(headers: dict): + """Minimal Starlette Request for get_redirect_uri() unit tests.""" + from fastapi import Request + raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()] + return Request({ + "type": "http", "method": "GET", "path": "/auth/login", + "headers": raw, "server": ("testserver", 80), "scheme": "http", + "query_string": b"", "client": ("127.0.0.1", 1234), + }) + + +def test_get_redirect_uri_from_host_header(): + """get_redirect_uri() derives the URI from the Host header.""" + from app.routers.auth import get_redirect_uri + uri = get_redirect_uri(_oauth_request({"host": "192.168.30.101:8080"})) + assert uri == "http://192.168.30.101:8080/auth/callback" + + +def test_get_redirect_uri_respects_forwarded_proto_and_host(): + """Behind a TLS reverse proxy, scheme/https + forwarded host win.""" + from app.routers.auth import get_redirect_uri + uri = get_redirect_uri(_oauth_request({ + "host": "flowdeck-internal:8080", + "x-forwarded-proto": "https", + "x-forwarded-host": "flowdeck.dracodev.net", + })) + assert uri == "https://flowdeck.dracodev.net/auth/callback" + + +def test_get_redirect_uri_env_override_wins(monkeypatch): + """An explicit OAUTH_REDIRECT_URI pins the URI regardless of request.""" + from app.routers.auth import get_redirect_uri + from app.config import settings + monkeypatch.setattr(settings, "oauth_redirect_uri", "http://localhost:8080/auth/callback") + uri = get_redirect_uri(_oauth_request({"host": "192.168.30.101:8080"})) + assert uri == "http://localhost:8080/auth/callback" + monkeypatch.undo() + + +def test_oauth_login_redirect_uri_dynamic(client): + """The authorize URL carries the request-derived redirect_uri (encoded).""" + resp = client.get( + "/auth/login?provider=gitea", + headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "flowdeck.dracodev.net"}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert "login/oauth" in resp.headers.get("location", "").lower() + assert "redirect_uri=https%3A%2F%2Fflowdeck.dracodev.net%2Fauth%2Fcallback" in resp.headers["location"] + + def test_oauth_callback_invalid_state(client): """GET /auth/callback?code=test&state=invalid — 400 for invalid state.""" resp = client.get("/auth/callback?code=test_code&state=invalid_state")