Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
25 changes: 17 additions & 8 deletions app/control_plane/lib/ankole_web/browser_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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)
)}
Expand All @@ -815,23 +823,24 @@ 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

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?(),
Expand Down
9 changes: 5 additions & 4 deletions app/control_plane/lib/ankole_web/pod_url.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}} =
Expand Down
5 changes: 4 additions & 1 deletion docs/design-docs/BrowserSessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading