From a5c3941ef1e18df2b9366b49c69bef8812857cf6 Mon Sep 17 00:00:00 2001 From: Sn0rrii <4687675+netscout2001@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:50:44 +0200 Subject: [PATCH] fix(oidc): strip trailing slash from issuer URL before building discovery URL (#985) --- backend/app/api/routes/mfa.py | 4 +- backend/tests/integration/test_mfa_api.py | 65 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/mfa.py b/backend/app/api/routes/mfa.py index 3919ef7d9..40b644fb9 100644 --- a/backend/app/api/routes/mfa.py +++ b/backend/app/api/routes/mfa.py @@ -1118,7 +1118,7 @@ async def oidc_authorize( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found or not enabled") # Fetch discovery document - discovery_url = f"{provider.issuer_url}/.well-known/openid-configuration" + discovery_url = f"{provider.issuer_url.rstrip('/')}/.well-known/openid-configuration" try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(discovery_url) @@ -1240,7 +1240,7 @@ async def oidc_callback( redirect_uri = f"{external_url}/api/v1/auth/oidc/callback" # ── Step 1: Fetch discovery document ──────────────────────────────── - discovery_url = f"{provider.issuer_url}/.well-known/openid-configuration" + discovery_url = f"{provider.issuer_url.rstrip('/')}/.well-known/openid-configuration" try: async with httpx.AsyncClient(timeout=10) as client: disc_resp = await client.get(discovery_url) diff --git a/backend/tests/integration/test_mfa_api.py b/backend/tests/integration/test_mfa_api.py index 9bed2371b..d4c7ff7fa 100644 --- a/backend/tests/integration/test_mfa_api.py +++ b/backend/tests/integration/test_mfa_api.py @@ -3014,3 +3014,68 @@ class TestOIDCExpiredTokenRejection: ) remaining = result.scalar_one_or_none() assert remaining is not None, "Expired exchange token must not be consumed by a rejected request" + + +# =========================================================================== +# Trailing slash in issuer_url — discovery URL must not contain double slash +# =========================================================================== + + +class TestOIDCIssuerUrlTrailingSlash: + """Providers like Authentik use issuer URLs with a trailing slash. + + BamBuddy must strip the slash before appending /.well-known/openid-configuration + to avoid a double-slash that results in a 404. + """ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_trailing_slash_issuer_url_fetches_correct_discovery_url( + self, async_client: AsyncClient + ): + from unittest.mock import AsyncMock, MagicMock, patch + + issuer_with_slash = "https://authentik.example.com/application/o/bambuddy/" + + admin_token = await _setup_and_login(async_client, "oidcslashadm", "oidcslashadm1") + create_resp = await async_client.post( + "/api/v1/auth/oidc/providers", + json={ + "name": "Authentik-Slash", + "issuer_url": issuer_with_slash, + "client_id": "bambuddy", + "client_secret": "secret", + "scopes": "openid email profile", + "is_enabled": True, + "auto_create_users": False, + }, + headers=_auth_header(admin_token), + ) + assert create_resp.status_code == 201 + provider_id = create_resp.json()["id"] + + fake_discovery = { + "issuer": issuer_with_slash, + "authorization_endpoint": "https://authentik.example.com/application/o/bambuddy/authorize", + } + disc_resp = AsyncMock() + disc_resp.raise_for_status = MagicMock() + disc_resp.json = MagicMock(return_value=fake_discovery) + + mock_http = AsyncMock() + mock_http.get = AsyncMock(return_value=disc_resp) + + with patch("backend.app.api.routes.mfa.httpx.AsyncClient") as mock_cls: + mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + resp = await async_client.get(f"/api/v1/auth/oidc/authorize/{provider_id}") + + assert resp.status_code == 200 + called_url = mock_http.get.call_args_list[0][0][0] + assert "//" not in called_url.replace("https://", ""), ( + f"Discovery URL must not contain double slash: {called_url}" + ) + assert called_url.endswith("/.well-known/openid-configuration"), ( + f"Expected discovery URL to end with /.well-known/openid-configuration, got: {called_url}" + )