fix: connexion OAuth Gitea — redirect URI invalide (erreur 'Unregistered Redirect URI')
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
This commit is contained in:
@@ -9,6 +9,13 @@ GITEA_WEBHOOK_SECRET=
|
|||||||
GITHUB_OAUTH_CLIENT_ID=
|
GITHUB_OAUTH_CLIENT_ID=
|
||||||
GITHUB_OAUTH_CLIENT_SECRET=
|
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 ──
|
||||||
APP_SECRET_KEY=change-me-to-random
|
APP_SECRET_KEY=change-me-to-random
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ class GitHubProvider(OAuthProvider):
|
|||||||
"client_id": self.client_id,
|
"client_id": self.client_id,
|
||||||
"client_secret": self.client_secret,
|
"client_secret": self.client_secret,
|
||||||
"code": code,
|
"code": code,
|
||||||
"redirect_uri": self.redirect_uri,
|
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||||
},
|
},
|
||||||
headers={"Accept": "application/json"},
|
headers={"Accept": "application/json"},
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-2
@@ -24,8 +24,9 @@ class Settings(BaseSettings):
|
|||||||
# Standalone mode
|
# Standalone mode
|
||||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||||
|
|
||||||
# OAuth2
|
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
||||||
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
||||||
|
oauth_redirect_uri: str = ""
|
||||||
|
|
||||||
# Webhook
|
# Webhook
|
||||||
webhook_base_url: str = "http://localhost:8080"
|
webhook_base_url: str = "http://localhost:8080"
|
||||||
|
|||||||
+27
-8
@@ -13,6 +13,24 @@ from app.config import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
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 = """<!DOCTYPE html>
|
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -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
|
# Encode auth mode in state to survive session loss during OAuth redirect
|
||||||
signed_state = f"{state}:{mode}" if mode else state
|
signed_state = f"{state}:{mode}" if mode else state
|
||||||
request.session["oauth_mode"] = mode
|
request.session["oauth_mode"] = mode
|
||||||
# Dynamic redirect URI based on incoming Host header
|
# Redirect URI derived from the incoming request (scheme-aware); stored in
|
||||||
host = request.headers.get("host", "localhost:8080")
|
# session so the callback reuses the EXACT same URI for token exchange
|
||||||
dynamic_redirect_uri = f"http://{host}/auth/callback"
|
redirect_uri = get_redirect_uri(request)
|
||||||
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link"))
|
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)
|
return RedirectResponse(url=auth_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@@ -282,10 +301,10 @@ async def callback(
|
|||||||
if not oauth_provider:
|
if not oauth_provider:
|
||||||
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
||||||
|
|
||||||
# Exchange code for token — use dynamic redirect URI matching the authorize step
|
# Exchange code for token — reuse the redirect URI from the authorize step
|
||||||
host = request.headers.get("host", "localhost:8080")
|
# (stored in session), falling back to deriving it from this request
|
||||||
dynamic_redirect_uri = f"http://{host}/auth/callback"
|
redirect_uri = request.session.get("oauth_redirect_uri") or get_redirect_uri(request)
|
||||||
token_data = await oauth_provider.exchange_code(code, redirect_uri=dynamic_redirect_uri)
|
token_data = await oauth_provider.exchange_code(code, redirect_uri=redirect_uri)
|
||||||
if not token_data:
|
if not token_data:
|
||||||
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
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):
|
def test_oauth_callback_invalid_state(client):
|
||||||
"""GET /auth/callback?code=test&state=invalid — 400 for invalid state."""
|
"""GET /auth/callback?code=test&state=invalid — 400 for invalid state."""
|
||||||
resp = client.get("/auth/callback?code=test_code&state=invalid_state")
|
resp = client.get("/auth/callback?code=test_code&state=invalid_state")
|
||||||
|
|||||||
Reference in New Issue
Block a user