feat: idp initiated saml authn (#9716)

Support IDP initiated SAML authentication.
This commit is contained in:
Karan Balani
2025-11-29 00:59:44 +05:30
committed by GitHub
parent b752fdd30a
commit e8ce7b22f5
11 changed files with 161 additions and 9 deletions

View File

@@ -129,6 +129,12 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
return &authtypes.AuthNProviderInfo{
RelayStatePath: nil,
}
}
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)

View File

@@ -99,6 +99,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
state := authtypes.NewState(&url.URL{Path: "login"}, authDomain.StorableAuthDomain().ID).URL.String()
return &authtypes.AuthNProviderInfo{
RelayStatePath: &state,
}
}
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
certStore, err := a.getCertificateStore(authDomain)
if err != nil {

View File

@@ -22,4 +22,7 @@ type CallbackAuthN interface {
// Handle the callback from the provider.
HandleCallback(context.Context, url.Values) (*authtypes.CallbackIdentity, error)
// Get provider info such as `relay state`
ProviderInfo(context.Context, *authtypes.AuthDomain) *authtypes.AuthNProviderInfo
}

View File

@@ -117,6 +117,12 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
return &authtypes.AuthNProviderInfo{
RelayStatePath: nil,
}
}
func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain, provider *oidc.Provider) *oauth2.Config {
return &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().Google.ClientID,

View File

@@ -29,6 +29,9 @@ type Module interface {
// Delete an existing auth domain by id.
Delete(context.Context, valuer.UUID, valuer.UUID) error
// Get the IDP info of the domain provided.
GetAuthNProviderInfo(context.Context, *authtypes.AuthDomain) (*authtypes.AuthNProviderInfo)
}
type Handler interface {

View File

@@ -95,7 +95,7 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
authDomains := make([]*authtypes.GettableAuthDomain, len(domains))
for i, domain := range domains {
authDomains[i] = authtypes.NewGettableAuthDomainFromAuthDomain(domain)
authDomains[i] = authtypes.NewGettableAuthDomainFromAuthDomain(domain, handler.module.GetAuthNProviderInfo(ctx, domain))
}
render.Success(rw, http.StatusOK, authDomains)

View File

@@ -3,17 +3,19 @@ package implauthdomain
import (
"context"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
store authtypes.AuthDomainStore
store authtypes.AuthDomainStore
authNs map[authtypes.AuthNProvider]authn.AuthN
}
func NewModule(store authtypes.AuthDomainStore) authdomain.Module {
return &module{store: store}
func NewModule(store authtypes.AuthDomainStore, authNs map[authtypes.AuthNProvider]authn.AuthN) authdomain.Module {
return &module{store: store, authNs: authNs}
}
func (module *module) Create(ctx context.Context, domain *authtypes.AuthDomain) error {
@@ -24,6 +26,13 @@ func (module *module) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthD
return module.store.Get(ctx, id)
}
func (module *module) GetAuthNProviderInfo(ctx context.Context, domain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
return callbackAuthN.ProviderInfo(ctx, domain)
}
return &authtypes.AuthNProviderInfo{}
}
func (module *module) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.AuthDomain, error) {
return module.store.GetByOrgIDAndID(ctx, orgID, id)
}

View File

@@ -90,8 +90,8 @@ func NewModules(
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)), tokenizer, orgGetter),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
}

View File

@@ -31,6 +31,11 @@ var (
type GettableAuthDomain struct {
*StorableAuthDomain
*AuthDomainConfig
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
type AuthNProviderInfo struct {
RelayStatePath *string `json:"relayStatePath"`
}
type PostableAuthDomain struct {
@@ -103,10 +108,11 @@ func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain)
}, nil
}
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain) *GettableAuthDomain {
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: authDomain.StorableAuthDomain(),
AuthDomainConfig: authDomain.AuthDomainConfig(),
AuthNProviderInfo: authNProviderInfo,
}
}

View File

@@ -1,4 +1,4 @@
from typing import Callable
from typing import Callable, Dict, Any
from urllib.parse import urljoin
from xml.etree import ElementTree
@@ -121,6 +121,31 @@ def create_saml_client(
return _create_saml_client
@pytest.fixture(name="update_saml_client_attributes", scope="function")
def update_saml_client_attributes(
idp: types.TestContainerIDP
) -> Callable[[str, Dict[str, Any]], None]:
def _update_saml_client_attributes(client_id: str, attributes: Dict[str, Any]) -> None:
client = KeycloakAdmin(
server_url=idp.container.host_configs["6060"].base(),
username=IDP_ROOT_USERNAME,
password=IDP_ROOT_PASSWORD,
realm_name="master",
)
kc_client_id = client.get_client_id(client_id=client_id)
print("kc_client_id: " + kc_client_id)
payload = client.get_client(client_id=kc_client_id)
for attr_key, attr_value in attributes.items():
payload["attributes"][attr_key] = attr_value
client.update_client(client_id=kc_client_id, payload=payload)
return _update_saml_client_attributes
@pytest.fixture(name="create_oidc_client", scope="function")
def create_oidc_client(
idp: types.TestContainerIDP, signoz: types.SigNoz

View File

@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Callable, List
from typing import Callable, List, Dict, Any
import requests
from selenium import webdriver
@@ -26,6 +26,7 @@ def test_create_auth_domain(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
create_saml_client: Callable[[str, str], None],
update_saml_client_attributes: Callable[[str, Dict[str, Any]], None],
get_saml_settings: Callable[[], dict],
create_user_admin: Callable[[], None], # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
@@ -59,6 +60,43 @@ def test_create_auth_domain(
assert response.status_code == HTTPStatus.CREATED
# Get the domains from signoz
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
found_domain = None
if len(response.json()["data"]) > 0:
found_domain = next(
(
domain
for domain in response.json()["data"]
if domain["name"] == "saml.integration.test"
),
None,
)
relay_state_path = found_domain["authNProviderInfo"]["relayStatePath"]
assert relay_state_path is not None
# Get the relay state url from domains API
relay_state_url = signoz.self.host_configs["8080"].base() + "/" + relay_state_path
# Update the saml client with new attributes
update_saml_client_attributes(
f"{signoz.self.host_configs['8080'].address}:{signoz.self.host_configs['8080'].port}",
{
"saml_idp_initiated_sso_url_name": "idp-initiated-saml-test",
"saml_idp_initiated_sso_relay_state": relay_state_url,
"saml_assertion_consumer_url_post": signoz.self.host_configs["8080"].get("/api/v1/complete/saml")
}
)
def test_saml_authn(
signoz: SigNoz,
@@ -106,3 +144,51 @@ def test_saml_authn(
assert found_user is not None
assert found_user["role"] == "VIEWER"
def test_idp_initiated_saml_authn(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
driver: webdriver.Chrome,
create_user_idp: Callable[[str, str], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], str],
) -> None:
# Create a user in the idp.
create_user_idp("viewer.idp.initiated@saml.integration.test", "password", True)
# Get the session context from signoz which will give the SAML login URL.
session_context = get_session_context("viewer.idp.initiated@saml.integration.test")
assert len(session_context["orgs"]) == 1
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
idp_initiated_login_url = idp.container.host_configs["6060"].base() + "/realms/master/protocol/saml/clients/idp-initiated-saml-test"
driver.get(idp_initiated_login_url)
idp_login("viewer.idp.initiated@saml.integration.test", "password")
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Assert that the user was created in signoz.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "viewer.idp.initiated@saml.integration.test"
),
None,
)
assert found_user is not None
assert found_user["role"] == "VIEWER"