Files
signoz/pkg/modules/session/implsession/handler.go
Vikrant Gupta 7844fc1fe1
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
fix(authn): include base path in SSO callback and error-redirect URLs (#11588)
* fix(authn): include base path in SSO callback and error-redirect URLs

The SAML ACS URL and the OIDC/Google redirect URLs were built from the
site URL host plus a hardcoded path (e.g. /api/v1/complete/saml), dropping
the base path. When SigNoz is served under a sub-path (global.external_url
with a path, e.g. https://example.com/signoz), the API is served at
<prefix>/api/v1/complete/<provider>, so the identity provider was told to
call back to a path without the prefix and hit a 404.

Thread global.Config into the SAML/OIDC/Google callback providers and the
session handler, and prepend global.Config.ExternalPath() to the callback
paths and the SSO error redirect to /login. Root deployments are
unchanged since ExternalPath() returns "" without a configured sub-path.

* fix(authn): run callbackauthn suite with base path

* refactor(tests): self-contained base-path fixture for callbackauthn

Move the base-path setup out of the shared create_signoz factory and into
a package-scoped signoz fixture in the callbackauthn suite's own conftest
(same pattern as rootuser/conftest.py). When --base-path is set the fixture
appends SIGNOZ_GLOBAL_EXTERNAL__URL and the url-config prefix locally;
without it it behaves exactly like the global fixture. The shared factory
and docker config are left untouched.

* test(authn): add base-path SSO integration suite

Adds a dedicated `basepath` integration suite that serves SigNoz under a
hardcoded /signoz prefix (SIGNOZ_GLOBAL_EXTERNAL__URL) and exercises the SAML
and OIDC happy-path logins end-to-end. Every SigNoz API call is issued under
the prefix and the IdP callback (ACS / redirect URI) is registered with the
prefix, so the flow only passes when the backend builds prefixed callback URLs.

The shared TestContainerUrlConfig and create_signoz factory are left untouched.
The suite's conftest shadows the same-named auth fixtures (create_user_admin,
get_token, get_session_context, apply_license) with base-path-aware variants and
reuses the Keycloak/browser fixtures, which are not under the base path.

Google SSO is not covered: it requires the real accounts.google.com issuer and
a real Google login, so it cannot run against the local Keycloak IdP; it shares
the identical path.Join(ExternalPath, redirectPath) callback logic that SAML
and OIDC validate.

* revert: drop in-place base-path wiring from integration harness

Removes the --base-path flag, TestContainerUrlConfig.base_path, the idp.py and
02_saml.py .get() changes, and the callbackauthn base-path conftest fixture.
Base-path SSO is now covered by the dedicated `basepath` suite, so the shared
harness (TestContainerUrlConfig, create_signoz, callbackauthn) is back to its
original root-only form.

* refactor(test): remove apply_license fixture

* refactor(test): extract base-path-aware auth factories

Extract the session-context / token / token-pair / admin-registration logic
in fixtures/auth.py into reusable factory functions that take an optional
base_path (token_getter, session_context_getter, tokens_getter, register_admin),
with the fixtures delegating to them. Default base_path="" is byte-identical for
existing callers.

The basepath suite's conftest now reuses these factories with the /signoz prefix
as thin one-line fixture overrides instead of duplicating the request logic.

* refactor(test): give base-path admin registration a distinct cache key

register_admin takes an optional cache_key (default "create_user_admin"); the
basepath suite passes a distinct key so that under --reuse the admin marker
cached against the signoz-base-path container is not restored for (or from)
other suites' default signoz instance.
2026-06-08 14:15:04 +00:00

174 lines
4.7 KiB
Go

package implsession
import (
"context"
"net/http"
"net/url"
"path"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
module session.Module
globalConfig global.Config
}
func NewHandler(module session.Module, globalConfig global.Config) session.Handler {
return &handler{module: module, globalConfig: globalConfig}
}
func (handler *handler) GetSessionContext(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
email, err := valuer.NewEmail(req.URL.Query().Get("email"))
if err != nil {
render.Error(rw, err)
return
}
siteURL, err := url.Parse(req.URL.Query().Get("ref"))
if err != nil {
render.Error(rw, err)
return
}
sessionContext, err := handler.module.GetSessionContext(ctx, email, siteURL)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, sessionContext)
}
func (handler *handler) CreateSessionByEmailPassword(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
defer cancel()
body := new(authtypes.PostableEmailPasswordSession)
if err := binding.JSON.BindBody(req.Body, body); err != nil {
render.Error(rw, err)
return
}
token, err := handler.module.CreatePasswordAuthNSession(ctx, authtypes.AuthNProviderEmailPassword, body.Email, body.Password, body.OrgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, authtypes.NewGettableTokenFromToken(token, handler.module.GetRotationInterval(ctx)))
}
func (handler *handler) CreateSessionByGoogleCallback(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
defer cancel()
values := req.URL.Query()
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderGoogleAuth, values)
if err != nil {
http.Redirect(rw, req, handler.getRedirectURLFromErr(err), http.StatusSeeOther)
return
}
http.Redirect(rw, req, redirectURL, http.StatusSeeOther)
}
func (handler *handler) CreateSessionBySAMLCallback(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
defer cancel()
err := req.ParseForm()
if err != nil {
render.Error(rw, err)
return
}
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderSAML, req.Form)
if err != nil {
http.Redirect(rw, req, handler.getRedirectURLFromErr(err), http.StatusSeeOther)
return
}
http.Redirect(rw, req, redirectURL, http.StatusSeeOther)
}
func (handler *handler) CreateSessionByOIDCCallback(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
defer cancel()
values := req.URL.Query()
redirectURL, err := handler.module.CreateCallbackAuthNSession(ctx, authtypes.AuthNProviderOIDC, values)
if err != nil {
http.Redirect(rw, req, handler.getRedirectURLFromErr(err), http.StatusSeeOther)
return
}
http.Redirect(rw, req, redirectURL, http.StatusSeeOther)
}
func (handler *handler) RotateSession(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
body := new(authtypes.PostableRotateToken)
if err := binding.JSON.BindBody(req.Body, body); err != nil {
render.Error(rw, err)
return
}
accessToken, err := authtypes.AccessTokenFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
token, err := handler.module.RotateSession(ctx, accessToken, body.RefreshToken)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, authtypes.NewGettableTokenFromToken(token, handler.module.GetRotationInterval(ctx)))
}
func (handler *handler) DeleteSession(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
accessToken, err := authtypes.AccessTokenFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.DeleteSession(ctx, accessToken)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) getRedirectURLFromErr(err error) string {
values := errors.AsURLValues(err)
values.Add("callbackauthnerr", "true")
return (&url.URL{
// When UI is being served on a prefix, we need to redirect to the login page on the prefix.
Path: path.Join(handler.globalConfig.ExternalPath(), "/login"),
RawQuery: values.Encode(),
}).String()
}