diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 469828a7a..1461648a8 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -197,6 +197,10 @@ func NewServer(cfg Config) (*Server, error) { _, err := cfg.Trust.GetByDomainName(ctx, host) return err == nil }, + func(ctx context.Context, host string) bool { + _, err := cfg.Trust.GetByDomainName(ctx, host) + return err == nil + }, ), }, nil } diff --git a/pkg/server/api/authn/session_transfer.go b/pkg/server/api/authn/session_transfer.go new file mode 100644 index 000000000..88c313abf --- /dev/null +++ b/pkg/server/api/authn/session_transfer.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package authn + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const sessionTransferTTL = 60 * time.Second + +var ( + ErrInvalidSessionTransferToken = errors.New("invalid session transfer token") + ErrSessionTransferTokenExpired = errors.New("session transfer token expired") +) + +// SignSessionTransfer creates a signed, time-limited token containing a +// session ID and the intended redirect URL. The token format is +// base64(sessionID:continueURL:timestamp).signature. +func SignSessionTransfer(sessionID string, continueURL string, secret string) (string, error) { + if secret == "" { + return "", fmt.Errorf("cannot sign session transfer token: secret is empty") + } + + payload := sessionID + ":" + continueURL + ":" + strconv.FormatInt(time.Now().Unix(), 10) + encoded := base64.RawURLEncoding.EncodeToString([]byte(payload)) + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(encoded)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return encoded + "." + sig, nil +} + +// SessionTransferClaims holds the verified claims from a session transfer +// token. +type SessionTransferClaims struct { + SessionID string + ContinueURL string +} + +// VerifySessionTransfer verifies a session transfer token and returns +// the session ID and continue URL if the token is valid and not expired. +func VerifySessionTransfer(token string, secret string) (SessionTransferClaims, error) { + if secret == "" { + return SessionTransferClaims{}, fmt.Errorf("cannot verify session transfer token: secret is empty") + } + + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return SessionTransferClaims{}, ErrInvalidSessionTransferToken + } + + encoded, sig := parts[0], parts[1] + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(encoded)) + expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(sig), []byte(expectedSig)) { + return SessionTransferClaims{}, ErrInvalidSessionTransferToken + } + + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return SessionTransferClaims{}, ErrInvalidSessionTransferToken + } + + // Payload format: sessionID:continueURL:timestamp + // Use LastIndex to find the timestamp separator (timestamp is always last). + idx := strings.LastIndex(string(payload), ":") + if idx < 0 { + return SessionTransferClaims{}, ErrInvalidSessionTransferToken + } + + tsStr := string(payload[idx+1:]) + rest := string(payload[:idx]) + + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + return SessionTransferClaims{}, ErrInvalidSessionTransferToken + } + + if time.Since(time.Unix(ts, 0)) > sessionTransferTTL { + return SessionTransferClaims{}, ErrSessionTransferTokenExpired + } + + // Split rest into sessionID and continueURL. + sepIdx := strings.Index(rest, ":") + if sepIdx < 0 { + return SessionTransferClaims{}, ErrInvalidSessionTransferToken + } + + return SessionTransferClaims{ + SessionID: rest[:sepIdx], + ContinueURL: rest[sepIdx+1:], + }, nil +} diff --git a/pkg/server/api/authn/session_transfer_test.go b/pkg/server/api/authn/session_transfer_test.go new file mode 100644 index 000000000..003fd4597 --- /dev/null +++ b/pkg/server/api/authn/session_transfer_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package authn + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSignAndVerifySessionTransfer(t *testing.T) { + t.Parallel() + + secret := "test-secret-key" + sessionID := "ses_abc123" + continueURL := "https://custom.example.com/compliance" + + token, err := SignSessionTransfer(sessionID, continueURL, secret) + require.NoError(t, err) + require.NotEmpty(t, token) + + claims, err := VerifySessionTransfer(token, secret) + require.NoError(t, err) + assert.Equal(t, sessionID, claims.SessionID) + assert.Equal(t, continueURL, claims.ContinueURL) +} + +func TestVerifySessionTransfer_WrongSecret(t *testing.T) { + t.Parallel() + + token, err := SignSessionTransfer("ses_abc123", "https://example.com", "secret-a") + require.NoError(t, err) + + _, err = VerifySessionTransfer(token, "secret-b") + assert.ErrorIs(t, err, ErrInvalidSessionTransferToken) +} + +func TestVerifySessionTransfer_TamperedToken(t *testing.T) { + t.Parallel() + + token, err := SignSessionTransfer("ses_abc123", "https://example.com", "secret") + require.NoError(t, err) + + _, err = VerifySessionTransfer(token+"x", "secret") + assert.ErrorIs(t, err, ErrInvalidSessionTransferToken) +} + +func TestVerifySessionTransfer_MalformedToken(t *testing.T) { + t.Parallel() + + _, err := VerifySessionTransfer("not-a-valid-token", "secret") + assert.ErrorIs(t, err, ErrInvalidSessionTransferToken) +} + +func TestSignSessionTransfer_EmptySecret(t *testing.T) { + t.Parallel() + + _, err := SignSessionTransfer("ses_abc123", "https://example.com", "") + assert.Error(t, err) +} diff --git a/pkg/server/api/connect/v1/oidc_handler.go b/pkg/server/api/connect/v1/oidc_handler.go index 8610101ed..579786a35 100644 --- a/pkg/server/api/connect/v1/oidc_handler.go +++ b/pkg/server/api/connect/v1/oidc_handler.go @@ -15,8 +15,10 @@ package connect_v1 import ( + "context" "errors" "net/http" + "net/url" "strings" "github.com/go-chi/chi/v5" @@ -29,11 +31,17 @@ import ( "go.probo.inc/probo/pkg/server/api/authn" ) +// IsTrustCenterDomainFunc checks whether a given host is a trust center +// custom domain. +type IsTrustCenterDomainFunc func(ctx context.Context, host string) bool + type OIDCHandler struct { - iam *iam.Service - sessionCookie *authn.Cookie - logger *log.Logger - safeRedirect *saferedirect.SafeRedirect + iam *iam.Service + sessionCookie *authn.Cookie + cookieSecret string + logger *log.Logger + safeRedirect *saferedirect.SafeRedirect + isTrustCenterDomain IsTrustCenterDomainFunc } func NewOIDCHandler( @@ -41,12 +49,15 @@ func NewOIDCHandler( cookieConfig securecookie.Config, logger *log.Logger, allowedHost saferedirect.AllowedHostFunc, + isTrustCenterDomain IsTrustCenterDomainFunc, ) *OIDCHandler { return &OIDCHandler{ - iam: iam, - sessionCookie: authn.NewCookie(&cookieConfig), - logger: logger, - safeRedirect: saferedirect.New(allowedHost), + iam: iam, + sessionCookie: authn.NewCookie(&cookieConfig), + cookieSecret: cookieConfig.Secret, + logger: logger, + safeRedirect: saferedirect.New(allowedHost), + isTrustCenterDomain: isTrustCenterDomain, } } @@ -140,7 +151,46 @@ func (h *OIDCHandler) CallbackHandler(w http.ResponseWriter, r *http.Request) { h.sessionCookie.Set(w, rootSession) - h.safeRedirect.Redirect(w, r, continueURL, "/", http.StatusFound) + redirectURL := h.safeRedirect.GetSafeRedirectURL(ctx, continueURL, "/") + + if transferURL, ok := h.buildSessionTransferURL(ctx, redirectURL, rootSession.ID.String()); ok { + http.Redirect(w, r, transferURL, http.StatusFound) + return + } + + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// buildSessionTransferURL returns a session-transfer URL on the target trust +// center custom domain. This lets the custom domain set its own cookie for the +// session. +func (h *OIDCHandler) buildSessionTransferURL(ctx context.Context, redirectURL string, sessionID string) (string, bool) { + parsed, err := url.Parse(redirectURL) + if err != nil || !parsed.IsAbs() { + return "", false + } + + if !h.isTrustCenterDomain(ctx, parsed.Host) { + return "", false + } + + token, err := authn.SignSessionTransfer(sessionID, redirectURL, h.cookieSecret) + if err != nil { + h.logger.Error("cannot sign session transfer token", log.Error(err)) + return "", false + } + + transferURL := &url.URL{ + Scheme: parsed.Scheme, + Host: parsed.Host, + Path: "/api/trust/v1/session-transfer", + } + + q := transferURL.Query() + q.Set("token", token) + transferURL.RawQuery = q.Encode() + + return transferURL.String(), true } func parseOIDCProvider(s string) (coredata.OIDCProvider, error) { diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index a81e43afa..828d6b756 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -63,6 +63,7 @@ func NewMux( tokenSecret string, baseURL *baseurl.BaseURL, allowedRedirectHost saferedirect.AllowedHostFunc, + isTrustCenterDomain IsTrustCenterDomainFunc, ) *chi.Mux { r := chi.NewMux() @@ -74,7 +75,7 @@ func NewMux( router := r.With(sessionMiddleware, apiKeyMiddleware) - oidcHandler := NewOIDCHandler(svc, cookieConfig, logger, allowedRedirectHost) + oidcHandler := NewOIDCHandler(svc, cookieConfig, logger, allowedRedirectHost, isTrustCenterDomain) router.Handle("/graphql", graphqlHandler) router.Get("/saml/2.0/metadata", samlHandler.MetadataHandler) diff --git a/pkg/server/api/trust/v1/resolver.go b/pkg/server/api/trust/v1/resolver.go index c8fdb6d32..f6bb6e8db 100644 --- a/pkg/server/api/trust/v1/resolver.go +++ b/pkg/server/api/trust/v1/resolver.go @@ -83,6 +83,10 @@ func NewMux( r := chi.NewMux() r.Use(compliancepage.NewCompliancePagePresenceMiddleware()) + + sessionTransferHandler := NewSessionTransferHandler(iamSvc, cookieConfig, logger) + r.Get("/session-transfer", sessionTransferHandler.ServeHTTP) + r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig)) r.Use(compliancepage.NewMemberProvisioningMiddleware(trustSvc, logger)) diff --git a/pkg/server/api/trust/v1/session_transfer_handler.go b/pkg/server/api/trust/v1/session_transfer_handler.go new file mode 100644 index 000000000..70b41268b --- /dev/null +++ b/pkg/server/api/trust/v1/session_transfer_handler.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package trust_v1 + +import ( + "errors" + "net/http" + + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server/api/authn" +) + +type SessionTransferHandler struct { + iam *iam.Service + sessionCookie *authn.Cookie + cookieSecret string + logger *log.Logger +} + +func NewSessionTransferHandler( + iamSvc *iam.Service, + cookieConfig securecookie.Config, + logger *log.Logger, +) *SessionTransferHandler { + return &SessionTransferHandler{ + iam: iamSvc, + sessionCookie: authn.NewCookie(&cookieConfig), + cookieSecret: cookieConfig.Secret, + logger: logger, + } +} + +func (h *SessionTransferHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + token := r.URL.Query().Get("token") + if token == "" { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("missing token")) + return + } + + claims, err := authn.VerifySessionTransfer(token, h.cookieSecret) + if err != nil { + h.logger.WarnCtx(ctx, "invalid session transfer token", log.Error(err)) + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid or expired token")) + return + } + + continueURL := claims.ContinueURL + if continueURL == "" { + continueURL = "/" + } + + sessionID, err := gid.ParseGID(claims.SessionID) + if err != nil { + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid token")) + return + } + + session, err := h.iam.SessionService.GetSession(ctx, sessionID) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot get session for transfer", log.Error(err)) + httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid or expired token")) + return + } + + h.sessionCookie.Set(w, session) + + http.Redirect(w, r, continueURL, http.StatusFound) +}