From 52d877c4ed444c1c8dd966cb7e15bcf1ec411284 Mon Sep 17 00:00:00 2001 From: Jinx-1120 Date: Fri, 30 Oct 2026 11:24:40 +0800 Subject: [PATCH] Give console and Pod SSO buttons their authorization link The sign-in page builds each enterprise SSO button from the provider's loginPath. BrowserAuth set loginPath only for oauth and site logins, so the console and Pod login pages received null, and the browser opened /null?purpose=console&... and hit Phoenix.Router.NoRouteError. Console providers now link to /sessions/oidc/:provider_id/authorization on the current site host, because console login never leaves that host. Pod providers link to the same endpoint on the target Pod origin, as oauth and site providers already do. PodURL.oidc_authorization_path/1 owns the path, and BrowserSessions.md states which host each ceremony uses. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++++ .../lib/ankole_web/browser_auth.ex | 25 +++++++++++++------ app/control_plane/lib/ankole_web/pod_url.ex | 9 ++++--- .../controllers/auth_controller_test.exs | 16 +++++++++++- .../pod_membership_authentication_test.exs | 7 +++++- docs/design-docs/BrowserSessions.md | 5 +++- 6 files changed, 54 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72e93143..f83f97948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Version 2.1.0-alpha.6 (2026-10-30) + +- Fix enterprise SSO buttons on the Site Admin Console and Pod sign-in pages. + Ankole sent these buttons an empty sign-in link, so the browser opened + `/null` and showed a "no route found" error instead of the provider's sign-in + page. + ## Version 2.1.0-alpha.5 (2026-10-29) - Internal changes only. diff --git a/app/control_plane/lib/ankole_web/browser_auth.ex b/app/control_plane/lib/ankole_web/browser_auth.ex index e96476d72..cafd34a7f 100644 --- a/app/control_plane/lib/ankole_web/browser_auth.ex +++ b/app/control_plane/lib/ankole_web/browser_auth.ex @@ -782,11 +782,18 @@ defmodule AnkoleWeb.BrowserAuth do end end - defp providers_for_target(_conn, %{purpose: :console}), - do: LoginPolicy.login_providers() + # Console login stays on the site host, so its providers link to the current origin. + defp providers_for_target(_conn, %{purpose: :console}) do + with {:ok, providers} <- LoginPolicy.login_providers(), + do: {:ok, Enum.map(providers, &provider_login_path("", &1))} + end - defp providers_for_target(conn, %{purpose: :pod}) do + defp providers_for_target(conn, %{purpose: :pod, tenant_id: tenant_id}) do with {:ok, providers} <- LoginPolicy.login_providers() do + {:ok, tenant} = Ankole.Tenants.get(tenant_id) + origin = PodURL.origin(conn, tenant) + providers = Enum.map(providers, &provider_login_path(origin, &1)) + if conn.assigns[:pod_email_otp_disabled] == true, do: {:ok, Enum.reject(providers, &(&1["provider_id"] == "email_otp"))}, else: {:ok, providers} @@ -797,9 +804,10 @@ defmodule AnkoleWeb.BrowserAuth do with {:ok, client} <- Ankole.OIDC.get_active_client(request.request["client_id"]), {:ok, providers} <- LoginPolicy.providers(client, request.request) do {:ok, tenant} = Ankole.Tenants.get(request.tenant_id) + origin = PodURL.origin(conn, tenant) {:ok, - Enum.map(providers, &provider_login_path(conn, tenant, &1)) + Enum.map(providers, &provider_login_path(origin, &1)) |> Enum.reject( &(&1["provider_id"] == "email_otp" and conn.assigns[:pod_email_otp_disabled] == true) )} @@ -815,7 +823,8 @@ defmodule AnkoleWeb.BrowserAuth do Ankole.IdentityProviders.Login.list_login_providers() end) - oidc_providers = Enum.map(oidc, &provider_login_path(conn, tenant, &1)) + origin = PodURL.origin(conn, tenant) + oidc_providers = Enum.map(oidc, &provider_login_path(origin, &1)) {:ok, oidc_providers ++ Ankole.IdentityProviders.SiteSources.providers()} end @@ -823,15 +832,15 @@ defmodule AnkoleWeb.BrowserAuth do defp providers_for_target(_conn, %{purpose: :site}), do: {:ok, Ankole.IdentityProviders.SiteSources.providers()} - defp provider_login_path(conn, tenant, %{"kind" => "oidc"} = provider) do + defp provider_login_path(origin, %{"kind" => "oidc"} = provider) do Map.put( provider, "login_path", - PodURL.oidc_authorization_url(conn, tenant, provider["provider_id"]) + origin <> PodURL.oidc_authorization_path(provider["provider_id"]) ) end - defp provider_login_path(_conn, _tenant, provider), do: provider + defp provider_login_path(_origin, provider), do: provider defp perform_request_email_otp(conn, params) do with {:ok, true} <- SetupConfig.completed?(), diff --git a/app/control_plane/lib/ankole_web/pod_url.ex b/app/control_plane/lib/ankole_web/pod_url.ex index ae790a0a2..e327a5861 100644 --- a/app/control_plane/lib/ankole_web/pod_url.ex +++ b/app/control_plane/lib/ankole_web/pod_url.ex @@ -36,10 +36,11 @@ defmodule AnkoleWeb.PodURL do else: {:ok, login_url(conn, tenant, params)} end - def oidc_authorization_url(conn, tenant, provider_id) do - origin(conn, tenant) <> - "/sessions/oidc/" <> URI.encode(provider_id) <> "/authorization" - end + def oidc_authorization_url(conn, tenant, provider_id), + do: origin(conn, tenant) <> oidc_authorization_path(provider_id) + + def oidc_authorization_path(provider_id), + do: "/sessions/oidc/" <> URI.encode(provider_id) <> "/authorization" def oauth_return_to(conn, request) do if @host_policy do diff --git a/app/control_plane/test/ankole_web/controllers/auth_controller_test.exs b/app/control_plane/test/ankole_web/controllers/auth_controller_test.exs index 6ad4ef43b..c3c5ae277 100644 --- a/app/control_plane/test/ankole_web/controllers/auth_controller_test.exs +++ b/app/control_plane/test/ankole_web/controllers/auth_controller_test.exs @@ -45,11 +45,25 @@ defmodule AnkoleWeb.AuthControllerTest do true ) + providers_conn = + conn + |> forwarded_https_conn() + |> init_test_session(%{}) + |> api_get("/api/v2/browser/identity-providers", %{"purpose" => "console"}) + + assert %{"loginPath" => login_path} = + Enum.find( + api_response(providers_conn, 200)["providers"], + &(&1["providerID"] == "lark-main") + ) + + assert login_path == "/sessions/oidc/lark-main/authorization" + conn = conn |> forwarded_https_conn() |> init_test_session(%{}) - |> get("/sessions/oidc/lark-main/authorization", %{ + |> get(login_path, %{ "purpose" => "console", "return_to" => "/site-admin-console/agents" }) diff --git a/app/control_plane/test/ankole_web/pod_membership_authentication_test.exs b/app/control_plane/test/ankole_web/pod_membership_authentication_test.exs index 6a75eef44..7e24f0556 100644 --- a/app/control_plane/test/ankole_web/pod_membership_authentication_test.exs +++ b/app/control_plane/test/ankole_web/pod_membership_authentication_test.exs @@ -257,7 +257,12 @@ defmodule AnkoleWeb.PodMembershipAuthenticationTest do oidc_providers = Enum.filter(api_response(providers_conn, 200)["providers"], &(&1["kind"] == "oidc")) - assert [%{"providerID" => "company-sso", "kind" => "oidc"}] = oidc_providers + assert [%{"providerID" => "company-sso", "kind" => "oidc", "loginPath" => login_path}] = + oidc_providers + + login_uri = URI.parse(login_path) + assert login_uri.host == URI.parse(AnkoleWeb.PodURL.origin(conn, target)).host + assert login_uri.path == "/sessions/oidc/company-sso/authorization" Repo.with_tenant(target.id, fn -> assert {:ok, %{membership: :guest, used_count: 0}} = diff --git a/docs/design-docs/BrowserSessions.md b/docs/design-docs/BrowserSessions.md index 6af2b9810..ad34afa06 100644 --- a/docs/design-docs/BrowserSessions.md +++ b/docs/design-docs/BrowserSessions.md @@ -73,7 +73,10 @@ Login ceremonies target either a specific tenant or the site: while keeping tenant OIDC initiation and callbacks active. - Outbound enterprise SSO creates an `OidcState` row when the browser visits `/sessions/oidc/:provider_id/authorization`, and its callback completes - against that recorded tenant and provider. On shared site sign-in hosts, + against that recorded tenant and provider. `loginProviders` returns this + endpoint as each OIDC provider's `loginPath`: a `console` login keeps it on + the current site host, while `pod`, `oauth`, and tenant-targeted `site` + logins point it at the target Pod host. On shared site sign-in hosts, `POST /api/v2/browser/pod-logins` resolves a workspace slug into its `tenantID`, `displayName`, and active OIDC `providers` (including each provider's `authorizationURL` on the workspace host) without exposing disabled tenants.