diff --git a/navi/api/routes/auth.py b/navi/api/routes/auth.py index 0de33a5..b2597cf 100644 --- a/navi/api/routes/auth.py +++ b/navi/api/routes/auth.py @@ -21,24 +21,33 @@ router = APIRouter(prefix="/auth", tags=["auth"]) +def _get_redirect_uri(request: Request) -> str: + """Build redirect_uri from the incoming request, respecting reverse proxies.""" + # FastAPI's request.base_url includes scheme+host+port; strip trailing slash + base = str(request.base_url).rstrip("/") + return f"{base}/auth/callback" + + @router.get("/login") async def auth_login(request: Request) -> Response: """Redirect to gnexus-auth OAuth authorization endpoint.""" - client = get_gauth_client() + redirect_uri = _get_redirect_uri(request) + client = get_gauth_client(redirect_uri=redirect_uri) auth_request = client.build_authorization_request( return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"], ) - log.info("auth.login_redirect", state=auth_request.state[:8] + "...") + log.info("auth.login_redirect", state=auth_request.state[:8] + "...", redirect_uri=redirect_uri) return Response(status_code=302, headers={"Location": auth_request.authorization_url}) @router.get("/callback") async def auth_callback(code: str, state: str, request: Request) -> Response: """Handle OAuth callback from gnexus-auth.""" - client = get_gauth_client() + redirect_uri = _get_redirect_uri(request) + client = get_gauth_client(redirect_uri=redirect_uri) encryptor = get_encryptor() # Exchange code for tokens (sync IO wrapped in thread) diff --git a/navi/auth/client.py b/navi/auth/client.py index 2fdde56..3a9d403 100644 --- a/navi/auth/client.py +++ b/navi/auth/client.py @@ -1,4 +1,8 @@ -"""GAuthClient singleton wired to navi config.""" +"""GAuthClient singleton wired to navi config. + +Supports dynamic redirect_uri for multi-domain deployments while keeping +state and PKCE stores shared across all client instances. +""" from gnexus_gauth.client import GAuthClient from gnexus_gauth.config import GAuthConfig @@ -9,30 +13,60 @@ from navi.config import settings -# Singleton instances, lazily created -_gauth_client: GAuthClient | None = None +# Shared ephemeral stores — must be singletons so state/PKCE survive across +# per-request client instances with different redirect_uris. _state_store: InMemoryStateStore | None = None _pkce_store: InMemoryPkceStore | None = None +# Default client (uses redirect_uri from settings) — used for token refresh, +# fetch_user, and webhook parsing where redirect_uri is irrelevant. +_default_gauth_client: GAuthClient | None = None -def get_gauth_client() -> GAuthClient: - global _gauth_client, _state_store, _pkce_store - if _gauth_client is None: - config = GAuthConfig( - base_url=settings.gnexus_auth_base_url, - client_id=settings.gnexus_auth_client_id, - client_secret=settings.gnexus_auth_client_secret, - redirect_uri=settings.gnexus_auth_redirect_uri, - ) + +def _ensure_stores() -> tuple[InMemoryStateStore, InMemoryPkceStore]: + """Create shared stores if they don't exist.""" + global _state_store, _pkce_store + if _state_store is None: _state_store = InMemoryStateStore() + if _pkce_store is None: _pkce_store = InMemoryPkceStore() - _gauth_client = GAuthClient( - config=config, - token_endpoint=HttpTokenEndpoint(config), - runtime_user_provider=HttpRuntimeUserProvider(config), - webhook_verifier=HmacWebhookVerifier(config), - webhook_parser=JsonWebhookParser(), - state_store=_state_store, - pkce_store=_pkce_store, - ) - return _gauth_client + return _state_store, _pkce_store + + +def _make_client(redirect_uri: str) -> GAuthClient: + """Build a GAuthClient with the given redirect_uri and shared stores.""" + state_store, pkce_store = _ensure_stores() + config = GAuthConfig( + base_url=settings.gnexus_auth_base_url, + client_id=settings.gnexus_auth_client_id, + client_secret=settings.gnexus_auth_client_secret, + redirect_uri=redirect_uri, + ) + return GAuthClient( + config=config, + token_endpoint=HttpTokenEndpoint(config), + runtime_user_provider=HttpRuntimeUserProvider(config), + webhook_verifier=HmacWebhookVerifier(config), + webhook_parser=JsonWebhookParser(), + state_store=state_store, + pkce_store=pkce_store, + ) + + +def get_gauth_client(redirect_uri: str | None = None) -> GAuthClient: + """Return a GAuthClient. + + When *redirect_uri* is provided (e.g. OAuth login/callback), a fresh + client is built with that URI while keeping the shared state/PKCE stores. + + When *redirect_uri* is omitted, the cached default client (using the + redirect_uri from settings) is returned. This default is safe for + token refresh, fetch_user, and webhook parsing where redirect_uri is + not used. + """ + global _default_gauth_client + if redirect_uri is not None: + return _make_client(redirect_uri) + if _default_gauth_client is None: + _default_gauth_client = _make_client(settings.gnexus_auth_redirect_uri) + return _default_gauth_client