feat: add saml request to link to sessions (#9001)

# Which Problems Are Solved

It is currently not possible to use SAML with the Session API.

# How the Problems Are Solved

Add SAML service, to get and resolve SAML requests.
Add SAML session and SAML request aggregate, which can be linked to the
Session to get back a SAMLResponse from the API directly.

# Additional Changes

Update of dependency zitadel/saml to provide all functionality for
handling of SAML requests and responses.

# Additional Context

Closes #6053

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz 2024-12-19 12:11:40 +01:00 committed by GitHub
parent 50d2b26a28
commit c3b97a91a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 3947 additions and 22 deletions

View File

@ -563,6 +563,7 @@ OIDC:
DefaultBackChannelLogoutLifetime: 15m # ZITADEL_OIDC_DEFAULTBACKCHANNELLOGOUTLIFETIME
SAML:
DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2
ProviderConfig:
MetadataConfig:
Path: "/metadata" # ZITADEL_SAML_PROVIDERCONFIG_METADATACONFIG_PATH

View File

@ -49,6 +49,7 @@ import (
user_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/user/v3alpha"
userschema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/userschema/v3alpha"
"github.com/zitadel/zitadel/internal/api/grpc/resources/webkey/v3"
saml_v2 "github.com/zitadel/zitadel/internal/api/grpc/saml/v2"
session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2"
session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta"
settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
@ -530,7 +531,7 @@ func startAPIs(
store,
consolePath,
oidcServer.AuthCallbackURL(),
provider.AuthCallbackURL(samlProvider),
samlProvider.AuthCallbackURL(),
config.ExternalSecure,
userAgentInterceptor,
op.NewIssuerInterceptor(oidcServer.IssuerFromRequest).Handler,
@ -555,6 +556,10 @@ func startAPIs(
if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
return nil, err
}
// After SAML provider so that the callback endpoint can be used
if err := apis.RegisterService(ctx, saml_v2.CreateServer(commands, queries, samlProvider, config.ExternalSecure)); err != nil {
return nil, err
}
// handle grpc at last to be able to handle the root, because grpc and gateway require a lot of different prefixes
apis.RouteGRPC()
return apis, nil

View File

@ -142,12 +142,27 @@ curl --request POST \
}'
```
<!--
### SAML session
TODO: https://github.com/zitadel/zitadel/issues/6053
The following example shows you how you could use the events search to get all events where a user has authenticated using SAML.
-->
```bash
curl --request POST \
--url $CUSTOM-DOMAIN/admin/v1/events/_search \
--header "Authorization: Bearer $TOKEN" \
--header 'Content-Type: application/json' \
--data '{
"asc": true,
"limit": 1000,
"eventTypes": [
"saml_session.added",
"saml_session.saml_response.added"
],
"aggregateTypes": [
"saml_session"
]
}'
```
## Example: Get failed login attempt

2
go.mod
View File

@ -67,7 +67,7 @@ require (
github.com/zitadel/logging v0.6.1
github.com/zitadel/oidc/v3 v3.32.0
github.com/zitadel/passwap v0.6.0
github.com/zitadel/saml v0.2.0
github.com/zitadel/saml v0.3.3
github.com/zitadel/schema v1.3.0
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0

4
go.sum
View File

@ -743,8 +743,8 @@ github.com/zitadel/oidc/v3 v3.32.0 h1:Mw0EPZRC6h+OXAuT0Uk2BZIjJQNHLqUpaJCm6c3IBy
github.com/zitadel/oidc/v3 v3.32.0/go.mod h1:DyE/XClysRK/ozFaZSqlYamKVnTh4l6Ln25ihSNI03w=
github.com/zitadel/passwap v0.6.0 h1:m9F3epFC0VkBXu25rihSLGyHvWiNlCzU5kk8RoI+SXQ=
github.com/zitadel/passwap v0.6.0/go.mod h1:kqAiJ4I4eZvm3Y6oAk6hlEqlZZOkjMHraGXF90GG7LI=
github.com/zitadel/saml v0.2.0 h1:vv7r+Xz43eAPCb+fImMaospD+TWRZQDkb78AbSJRcL4=
github.com/zitadel/saml v0.2.0/go.mod h1:QqKcguOt7mMVI6tkEfpkyzwnYRdlmn3kYQj3VTPUw1g=
github.com/zitadel/saml v0.3.3 h1:Cn+1ZNeWlzMM7wxUxJfgNjXSW+Yu6UD4zWbpySA5GQM=
github.com/zitadel/saml v0.3.3/go.mod h1:QqKcguOt7mMVI6tkEfpkyzwnYRdlmn3kYQj3VTPUw1g=
github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0=
github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=

View File

@ -0,0 +1,367 @@
//go:build integration
package saml_test
import (
"context"
"net/url"
"os"
"regexp"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/crewjam/saml"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
var (
CTX context.Context
Instance *integration.Instance
Client saml_pb.SAMLServiceClient
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
Instance = integration.NewInstance(ctx)
Client = Instance.Client.SAMLv2
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
return m.Run()
}())
}
func TestServer_GetAuthRequest(t *testing.T) {
rootURL := "https://sp.example.com"
idpMetadata, err := Instance.GetSAMLIDPMetadata()
require.NoError(t, err)
spMiddlewareRedirect, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPRedirectBinding)
require.NoError(t, err)
spMiddlewarePost, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPPostBinding)
require.NoError(t, err)
acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0]
acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1]
project, err := Instance.CreateProject(CTX)
require.NoError(t, err)
_, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect)
require.NoError(t, err)
_, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost)
require.NoError(t, err)
now := time.Now()
tests := []struct {
name string
dep func() (string, error)
want *oidc_pb.GetAuthRequestResponse
wantErr bool
}{
{
name: "Not found",
dep: func() (string, error) {
return "123", nil
},
wantErr: true,
},
{
name: "success, redirect binding",
dep: func() (string, error) {
return Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding)
},
},
{
name: "success, post binding",
dep: func() (string, error) {
return Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authRequestID, err := tt.dep()
require.NoError(t, err)
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, err := Client.GetSAMLRequest(CTX, &saml_pb.GetSAMLRequestRequest{
SamlRequestId: authRequestID,
})
if tt.wantErr {
assert.Error(ttt, err)
return
}
assert.NoError(ttt, err)
authRequest := got.GetSamlRequest()
assert.NotNil(ttt, authRequest)
assert.Equal(ttt, authRequestID, authRequest.GetId())
assert.WithinRange(ttt, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second))
}, retryDuration, tick, "timeout waiting for expected saml request result")
})
}
}
func TestServer_CreateResponse(t *testing.T) {
idpMetadata, err := Instance.GetSAMLIDPMetadata()
require.NoError(t, err)
rootURLRedirect := "spredirect.example.com"
spMiddlewareRedirect, err := integration.CreateSAMLSP("https://"+rootURLRedirect, idpMetadata, saml.HTTPRedirectBinding)
require.NoError(t, err)
rootURLPost := "sppost.example.com"
spMiddlewarePost, err := integration.CreateSAMLSP("https://"+rootURLPost, idpMetadata, saml.HTTPPostBinding)
require.NoError(t, err)
acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0]
acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1]
project, err := Instance.CreateProject(CTX)
require.NoError(t, err)
_, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect)
require.NoError(t, err)
_, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost)
require.NoError(t, err)
sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: Instance.Users[integration.UserTypeOrgOwner].ID,
},
},
},
})
require.NoError(t, err)
tests := []struct {
name string
req *saml_pb.CreateResponseRequest
AuthError string
want *saml_pb.CreateResponseResponse
wantURL *url.URL
wantErr bool
}{
{
name: "Not found",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: "123",
ResponseKind: &saml_pb.CreateResponseRequest_Session{
Session: &saml_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: sessionResp.GetSessionToken(),
},
},
},
wantErr: true,
},
{
name: "session not found",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding)
require.NoError(t, err)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Session{
Session: &saml_pb.Session{
SessionId: "foo",
SessionToken: "bar",
},
},
},
wantErr: true,
},
{
name: "session token invalid",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding)
require.NoError(t, err)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Session{
Session: &saml_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: "bar",
},
},
},
wantErr: true,
},
{
name: "fail callback, post",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding)
require.NoError(t, err)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Error{
Error: &saml_pb.AuthorizationError{
Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED,
ErrorDescription: gu.Ptr("nope"),
},
},
},
want: &saml_pb.CreateResponseResponse{
Url: regexp.QuoteMeta(`https://` + rootURLPost + `/saml/acs`),
Binding: &saml_pb.CreateResponseResponse_Post{Post: &saml_pb.PostResponse{
RelayState: "notempty",
SamlResponse: "notempty",
}},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantErr: false,
},
{
name: "fail callback, post, already failed",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding)
require.NoError(t, err)
Instance.FailSAMLAuthRequest(CTX, authRequestID, saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Error{
Error: &saml_pb.AuthorizationError{
Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED,
ErrorDescription: gu.Ptr("nope"),
},
},
},
wantErr: true,
},
{
name: "fail callback, redirect",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding)
require.NoError(t, err)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Error{
Error: &saml_pb.AuthorizationError{
Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED,
ErrorDescription: gu.Ptr("nope"),
},
},
},
want: &saml_pb.CreateResponseResponse{
Url: `https:\/\/` + rootURLRedirect + `\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)`,
Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantErr: false,
},
{
name: "callback, redirect",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding)
require.NoError(t, err)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Session{
Session: &saml_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: sessionResp.GetSessionToken(),
},
},
},
want: &saml_pb.CreateResponseResponse{
Url: `https:\/\/` + rootURLRedirect + `\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`,
Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantErr: false,
},
{
name: "callback, post",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding)
require.NoError(t, err)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Session{
Session: &saml_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: sessionResp.GetSessionToken(),
},
},
},
want: &saml_pb.CreateResponseResponse{
Url: regexp.QuoteMeta(`https://` + rootURLPost + `/saml/acs`),
Binding: &saml_pb.CreateResponseResponse_Post{Post: &saml_pb.PostResponse{
RelayState: "notempty",
SamlResponse: "notempty",
}},
Details: &object.Details{
ChangeDate: timestamppb.Now(),
ResourceOwner: Instance.ID(),
},
},
wantErr: false,
},
{
name: "callback, post",
req: &saml_pb.CreateResponseRequest{
SamlRequestId: func() string {
authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding)
require.NoError(t, err)
Instance.SuccessfulSAMLAuthRequest(CTX, Instance.Users[integration.UserTypeOrgOwner].ID, authRequestID)
return authRequestID
}(),
ResponseKind: &saml_pb.CreateResponseRequest_Session{
Session: &saml_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: sessionResp.GetSessionToken(),
},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.CreateResponse(CTX, tt.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
integration.AssertDetails(t, tt.want, got)
if tt.want != nil {
assert.Regexp(t, regexp.MustCompile(tt.want.Url), got.GetUrl())
if tt.want.GetPost() != nil {
assert.NotEmpty(t, got.GetPost().GetRelayState())
assert.NotEmpty(t, got.GetPost().GetSamlResponse())
}
if tt.want.GetRedirect() != nil {
assert.NotNil(t, got.GetRedirect())
}
}
})
}
}

View File

@ -0,0 +1,112 @@
package saml
import (
"context"
"github.com/zitadel/logging"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/api/saml"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2"
)
func (s *Server) GetAuthRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) {
authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true)
if err != nil {
logging.WithError(err).Error("query samlRequest by ID")
return nil, err
}
return &saml_pb.GetSAMLRequestResponse{
SamlRequest: samlRequestToPb(authRequest),
}, nil
}
func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest {
return &saml_pb.SAMLRequest{
Id: a.ID,
CreationDate: timestamppb.New(a.CreationDate),
}
}
func (s *Server) CreateResponse(ctx context.Context, req *saml_pb.CreateResponseRequest) (*saml_pb.CreateResponseResponse, error) {
switch v := req.GetResponseKind().(type) {
case *saml_pb.CreateResponseRequest_Error:
return s.failSAMLRequest(ctx, req.GetSamlRequestId(), v.Error)
case *saml_pb.CreateResponseRequest_Session:
return s.linkSessionToSAMLRequest(ctx, req.GetSamlRequestId(), v.Session)
default:
return nil, zerrors.ThrowUnimplementedf(nil, "SAMLv2-0Tfak3fBS0", "verification oneOf %T in method CreateResponse not implemented", v)
}
}
func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*saml_pb.CreateResponseResponse, error) {
details, aar, err := s.command.FailSAMLRequest(ctx, samlRequestID, errorReasonToDomain(ae.GetError()))
if err != nil {
return nil, err
}
authReq := &saml.AuthRequestV2{CurrentSAMLRequest: aar}
url, body, err := s.idp.CreateErrorResponse(authReq, errorReasonToDomain(ae.GetError()), ae.GetErrorDescription())
if err != nil {
return nil, err
}
return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil
}
func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) {
details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true)
if err != nil {
return nil, err
}
authReq := &saml.AuthRequestV2{CurrentSAMLRequest: aar}
url, body, err := s.idp.CreateResponse(ctx, authReq)
if err != nil {
return nil, err
}
return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil
}
func createCallbackResponseFromBinding(details *domain.ObjectDetails, url string, body string, relayState string) *saml_pb.CreateResponseResponse {
resp := &saml_pb.CreateResponseResponse{
Details: object.DomainToDetailsPb(details),
Url: url,
}
if body != "" {
resp.Binding = &saml_pb.CreateResponseResponse_Post{
Post: &saml_pb.PostResponse{
RelayState: relayState,
SamlResponse: body,
},
}
} else {
resp.Binding = &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}
}
return resp
}
func errorReasonToDomain(errorReason saml_pb.ErrorReason) domain.SAMLErrorReason {
switch errorReason {
case saml_pb.ErrorReason_ERROR_REASON_UNSPECIFIED:
return domain.SAMLErrorReasonUnspecified
case saml_pb.ErrorReason_ERROR_REASON_VERSION_MISSMATCH:
return domain.SAMLErrorReasonVersionMissmatch
case saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED:
return domain.SAMLErrorReasonAuthNFailed
case saml_pb.ErrorReason_ERROR_REASON_INVALID_ATTR_NAME_OR_VALUE:
return domain.SAMLErrorReasonInvalidAttrNameOrValue
case saml_pb.ErrorReason_ERROR_REASON_INVALID_NAMEID_POLICY:
return domain.SAMLErrorReasonInvalidNameIDPolicy
case saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED:
return domain.SAMLErrorReasonRequestDenied
case saml_pb.ErrorReason_ERROR_REASON_REQUEST_UNSUPPORTED:
return domain.SAMLErrorReasonRequestUnsupported
case saml_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_BINDING:
return domain.SAMLErrorReasonUnsupportedBinding
default:
return domain.SAMLErrorReasonUnspecified
}
}

View File

@ -0,0 +1,59 @@
package saml
import (
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/api/saml"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2"
)
var _ saml_pb.SAMLServiceServer = (*Server)(nil)
type Server struct {
saml_pb.UnimplementedSAMLServiceServer
command *command.Commands
query *query.Queries
idp *saml.Provider
externalSecure bool
}
type Config struct{}
func CreateServer(
command *command.Commands,
query *query.Queries,
idp *saml.Provider,
externalSecure bool,
) *Server {
return &Server{
command: command,
query: query,
idp: idp,
externalSecure: externalSecure,
}
}
func (s *Server) RegisterServer(grpcServer *grpc.Server) {
saml_pb.RegisterSAMLServiceServer(grpcServer, s)
}
func (s *Server) AppName() string {
return saml_pb.SAMLService_ServiceDesc.ServiceName
}
func (s *Server) MethodPrefix() string {
return saml_pb.SAMLService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {
return saml_pb.SAMLService_AuthMethods
}
func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
return saml_pb.RegisterSAMLServiceHandler
}

View File

@ -0,0 +1,99 @@
package saml
import (
"context"
"encoding/base64"
"net/url"
"github.com/zitadel/saml/pkg/provider"
"github.com/zitadel/saml/pkg/provider/models"
"github.com/zitadel/saml/pkg/provider/xml"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
)
func (p *Provider) CreateErrorResponse(authReq models.AuthRequestInt, reason domain.SAMLErrorReason, description string) (string, string, error) {
resp := &provider.Response{
ProtocolBinding: authReq.GetBindingType(),
RelayState: authReq.GetRelayState(),
AcsUrl: authReq.GetAccessConsumerServiceURL(),
RequestID: authReq.GetAuthRequestID(),
Issuer: authReq.GetDestination(),
Audience: authReq.GetIssuer(),
}
return createResponse(p.AuthCallbackErrorResponse(resp, domain.SAMLErrorReasonToString(reason), description), authReq.GetBindingType(), authReq.GetAccessConsumerServiceURL(), resp.RelayState, resp.SigAlg, resp.Signature)
}
func (p *Provider) CreateResponse(ctx context.Context, authReq models.AuthRequestInt) (string, string, error) {
resp := &provider.Response{
ProtocolBinding: authReq.GetBindingType(),
RelayState: authReq.GetRelayState(),
AcsUrl: authReq.GetAccessConsumerServiceURL(),
RequestID: authReq.GetAuthRequestID(),
Issuer: authReq.GetDestination(),
Audience: authReq.GetIssuer(),
}
samlResponse, err := p.AuthCallbackResponse(ctx, authReq, resp)
if err != nil {
return "", "", err
}
if err := p.command.CreateSAMLSessionFromSAMLRequest(
setContextUserSystem(ctx),
authReq.GetID(),
samlComplianceChecker(),
samlResponse.Id,
p.Expiration(),
); err != nil {
return "", "", err
}
return createResponse(samlResponse, authReq.GetBindingType(), authReq.GetAccessConsumerServiceURL(), resp.RelayState, resp.SigAlg, resp.Signature)
}
func createResponse(samlResponse interface{}, binding, acs, relayState, sigAlg, sig string) (string, string, error) {
respData, err := xml.Marshal(samlResponse)
if err != nil {
return "", "", err
}
switch binding {
case provider.PostBinding:
return acs, base64.StdEncoding.EncodeToString(respData), nil
case provider.RedirectBinding:
respData, err := xml.DeflateAndBase64(respData)
if err != nil {
return "", "", err
}
parsed, err := url.Parse(acs)
if err != nil {
return "", "", err
}
values := parsed.Query()
values.Add("SAMLResponse", string(respData))
values.Add("RelayState", relayState)
values.Add("SigAlg", sigAlg)
values.Add("Signature", sig)
parsed.RawQuery = values.Encode()
return parsed.String(), "", nil
}
return "", "", nil
}
func setContextUserSystem(ctx context.Context) context.Context {
data := authz.CtxData{
UserID: "SYSTEM",
}
return authz.SetCtxData(ctx, data)
}
func samlComplianceChecker() command.SAMLRequestComplianceChecker {
return func(_ context.Context, samlReq *command.SAMLRequestWriteModel) error {
if err := samlReq.CheckAuthenticated(); err != nil {
return err
}
return nil
}
}

View File

@ -0,0 +1,45 @@
package saml
import (
"github.com/zitadel/saml/pkg/provider/models"
"github.com/zitadel/zitadel/internal/command"
)
var _ models.AuthRequestInt = &AuthRequestV2{}
type AuthRequestV2 struct {
*command.CurrentSAMLRequest
}
func (a *AuthRequestV2) GetApplicationID() string {
return a.ApplicationID
}
func (a *AuthRequestV2) GetID() string {
return a.ID
}
func (a *AuthRequestV2) GetRelayState() string {
return a.RelayState
}
func (a *AuthRequestV2) GetAccessConsumerServiceURL() string {
return a.ACSURL
}
func (a *AuthRequestV2) GetAuthRequestID() string {
return a.RequestID
}
func (a *AuthRequestV2) GetBindingType() string {
return a.Binding
}
func (a *AuthRequestV2) GetIssuer() string {
return a.Issuer
}
func (a *AuthRequestV2) GetDestination() string {
return a.Destination
}
func (a *AuthRequestV2) GetUserID() string {
return a.UserID
}
func (a *AuthRequestV2) Done() bool {
return a.UserID != "" && a.SessionID != ""
}

View File

@ -25,6 +25,12 @@ const (
type Config struct {
ProviderConfig *provider.Config
DefaultLoginURLV2 string
}
type Provider struct {
*provider.Provider
command *command.Commands
}
func NewProvider(
@ -40,7 +46,7 @@ func NewProvider(
instanceHandler,
userAgentCookie func(http.Handler) http.Handler,
accessHandler *middleware.AccessInterceptor,
) (*provider.Provider, error) {
) (*Provider, error) {
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount}
provStorage, err := newStorage(
@ -51,6 +57,8 @@ func NewProvider(
certEncAlg,
es,
projections,
fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID),
conf.DefaultLoginURLV2,
)
if err != nil {
return nil, err
@ -73,12 +81,19 @@ func NewProvider(
options = append(options, provider.WithAllowInsecure())
}
return provider.NewProvider(
p, err := provider.NewProvider(
provStorage,
HandlerPrefix,
conf.ProviderConfig,
options...,
)
if err != nil {
return nil, err
}
return &Provider{
p,
command,
}, nil
}
func newStorage(
@ -89,6 +104,8 @@ func newStorage(
certEncAlg crypto.EncryptionAlgorithm,
es *eventstore.Eventstore,
db *database.DB,
defaultLoginURL string,
defaultLoginURLV2 string,
) (*Storage, error) {
return &Storage{
encAlg: encAlg,
@ -98,7 +115,8 @@ func newStorage(
repo: repo,
command: command,
query: query,
defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID),
defaultLoginURL: defaultLoginURL,
defaultLoginURLv2: defaultLoginURLV2,
}, nil
}

View File

@ -3,6 +3,7 @@ package saml
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/dop251/goja"
@ -16,6 +17,7 @@ import (
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/actions/object"
"github.com/zitadel/zitadel/internal/activity"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/auth/repository"
"github.com/zitadel/zitadel/internal/command"
@ -33,6 +35,10 @@ var _ provider.IdentityProviderStorage = &Storage{}
var _ provider.AuthStorage = &Storage{}
var _ provider.UserStorage = &Storage{}
const (
LoginClientHeader = "x-zitadel-login-client"
)
type Storage struct {
certChan <-chan interface{}
defaultCertificateLifetime time.Duration
@ -52,6 +58,7 @@ type Storage struct {
query *query.Queries
defaultLoginURL string
defaultLoginURLv2 string
}
func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*serviceprovider.ServiceProvider, error) {
@ -64,7 +71,12 @@ func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*servicep
&serviceprovider.Config{
Metadata: app.SAMLConfig.Metadata,
},
p.defaultLoginURL,
func(id string) string {
if strings.HasPrefix(id, command.IDPrefixV2) {
return p.defaultLoginURLv2 + id
}
return p.defaultLoginURL + id
},
)
}
@ -95,6 +107,38 @@ func (p *Storage) GetResponseSigningKey(ctx context.Context) (*key.CertificateAn
func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID string) (_ models.AuthRequestInt, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
headers, _ := http_utils.HeadersFromCtx(ctx)
if loginClient := headers.Get(LoginClientHeader); loginClient != "" {
return p.createAuthRequestLoginClient(ctx, req, acsUrl, protocolBinding, relayState, applicationID, loginClient)
}
return p.createAuthRequest(ctx, req, acsUrl, protocolBinding, relayState, applicationID)
}
func (p *Storage) createAuthRequestLoginClient(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID, loginClient string) (_ models.AuthRequestInt, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
samlRequest := &command.SAMLRequest{
ApplicationID: applicationID,
ACSURL: acsUrl,
RelayState: relayState,
RequestID: req.Id,
Binding: protocolBinding,
Issuer: req.Issuer.Text,
Destination: req.Destination,
LoginClient: loginClient,
}
aar, err := p.command.AddSAMLRequest(ctx, samlRequest)
if err != nil {
return nil, err
}
return &AuthRequestV2{aar}, nil
}
func (p *Storage) createAuthRequest(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID string) (_ models.AuthRequestInt, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok {
return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-sd436", "no user agent id")
@ -113,6 +157,15 @@ func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequest
func (p *Storage) AuthRequestByID(ctx context.Context, id string) (_ models.AuthRequestInt, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if strings.HasPrefix(id, command.IDPrefixV2) {
req, err := p.command.GetCurrentSAMLRequest(ctx, id)
if err != nil {
return nil, err
}
return &AuthRequestV2{req}, nil
}
userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok {
return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-D3g21", "no user agent id")

View File

@ -0,0 +1,161 @@
package command
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/repository/samlrequest"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type SAMLRequest struct {
ID string
LoginClient string
ApplicationID string
ACSURL string
RelayState string
RequestID string
Binding string
Issuer string
Destination string
}
type CurrentSAMLRequest struct {
*SAMLRequest
SessionID string
UserID string
AuthMethods []domain.UserAuthMethodType
AuthTime time.Time
}
func (c *Commands) AddSAMLRequest(ctx context.Context, samlRequest *SAMLRequest) (_ *CurrentSAMLRequest, err error) {
id, err := c.idGenerator.Next()
if err != nil {
return nil, err
}
samlRequest.ID = IDPrefixV2 + id
writeModel, err := c.getSAMLRequestWriteModel(ctx, samlRequest.ID)
if err != nil {
return nil, err
}
if writeModel.SAMLRequestState != domain.SAMLRequestStateUnspecified {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-MO3vmsMLUt", "Errors.SAMLRequest.AlreadyExisting")
}
err = c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewAddedEvent(
ctx,
&samlrequest.NewAggregate(samlRequest.ID, authz.GetInstance(ctx).InstanceID()).Aggregate,
samlRequest.LoginClient,
samlRequest.ApplicationID,
samlRequest.ACSURL,
samlRequest.RelayState,
samlRequest.RequestID,
samlRequest.Binding,
samlRequest.Issuer,
samlRequest.Destination,
))
if err != nil {
return nil, err
}
return samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil
}
func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool) (*domain.ObjectDetails, *CurrentSAMLRequest, error) {
writeModel, err := c.getSAMLRequestWriteModel(ctx, id)
if err != nil {
return nil, nil, err
}
if writeModel.SAMLRequestState == domain.SAMLRequestStateUnspecified {
return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-GH3PVLSfXC", "Errors.SAMLRequest.NotExisting")
}
if writeModel.SAMLRequestState != domain.SAMLRequestStateAdded {
return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-ttPKNdAIFT", "Errors.SAMLRequest.AlreadyHandled")
}
if checkLoginClient && authz.GetCtxData(ctx).UserID != writeModel.LoginClient {
return nil, nil, zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient")
}
sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID())
err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
if err != nil {
return nil, nil, err
}
if err = sessionWriteModel.CheckIsActive(); err != nil {
return nil, nil, err
}
if err := c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID); err != nil {
return nil, nil, err
}
if err := c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewSessionLinkedEvent(
ctx, &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate,
sessionID,
sessionWriteModel.UserID,
sessionWriteModel.AuthenticationTime(),
sessionWriteModel.AuthMethodTypes(),
)); err != nil {
return nil, nil, err
}
return writeModelToObjectDetails(&writeModel.WriteModel), samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil
}
func (c *Commands) FailSAMLRequest(ctx context.Context, id string, reason domain.SAMLErrorReason) (*domain.ObjectDetails, *CurrentSAMLRequest, error) {
writeModel, err := c.getSAMLRequestWriteModel(ctx, id)
if err != nil {
return nil, nil, err
}
if writeModel.SAMLRequestState != domain.SAMLRequestStateAdded {
return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled")
}
err = c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewFailedEvent(
ctx,
&samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate,
reason,
))
if err != nil {
return nil, nil, err
}
return writeModelToObjectDetails(&writeModel.WriteModel), samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil
}
func samlRequestWriteModelToCurrentSAMLRequest(writeModel *SAMLRequestWriteModel) (_ *CurrentSAMLRequest) {
return &CurrentSAMLRequest{
SAMLRequest: &SAMLRequest{
ID: writeModel.AggregateID,
LoginClient: writeModel.LoginClient,
ApplicationID: writeModel.ApplicationID,
ACSURL: writeModel.ACSURL,
RelayState: writeModel.RelayState,
RequestID: writeModel.RequestID,
Binding: writeModel.Binding,
Issuer: writeModel.Issuer,
Destination: writeModel.Destination,
},
SessionID: writeModel.SessionID,
UserID: writeModel.UserID,
AuthMethods: writeModel.AuthMethods,
AuthTime: writeModel.AuthTime,
}
}
func (c *Commands) GetCurrentSAMLRequest(ctx context.Context, id string) (_ *CurrentSAMLRequest, err error) {
wm, err := c.getSAMLRequestWriteModel(ctx, id)
if err != nil {
return nil, err
}
return samlRequestWriteModelToCurrentSAMLRequest(wm), nil
}
func (c *Commands) getSAMLRequestWriteModel(ctx context.Context, id string) (writeModel *SAMLRequestWriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = NewSAMLRequestWriteModel(ctx, id)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err
}
return writeModel, nil
}

View File

@ -0,0 +1,88 @@
package command
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/samlrequest"
"github.com/zitadel/zitadel/internal/zerrors"
)
type SAMLRequestWriteModel struct {
eventstore.WriteModel
aggregate *eventstore.Aggregate
LoginClient string
ApplicationID string
ACSURL string
RelayState string
RequestID string
Binding string
Issuer string
Destination string
SessionID string
UserID string
AuthTime time.Time
AuthMethods []domain.UserAuthMethodType
SAMLRequestState domain.SAMLRequestState
}
func NewSAMLRequestWriteModel(ctx context.Context, id string) *SAMLRequestWriteModel {
return &SAMLRequestWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: id,
},
aggregate: &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate,
}
}
func (m *SAMLRequestWriteModel) Reduce() error {
for _, event := range m.Events {
switch e := event.(type) {
case *samlrequest.AddedEvent:
m.LoginClient = e.LoginClient
m.ApplicationID = e.ApplicationID
m.ACSURL = e.ACSURL
m.RelayState = e.RelayState
m.RequestID = e.RequestID
m.Binding = e.Binding
m.Issuer = e.Issuer
m.Destination = e.Destination
m.SAMLRequestState = domain.SAMLRequestStateAdded
case *samlrequest.SessionLinkedEvent:
m.SessionID = e.SessionID
m.UserID = e.UserID
m.AuthTime = e.AuthTime
m.AuthMethods = e.AuthMethods
case *samlrequest.FailedEvent:
m.SAMLRequestState = domain.SAMLRequestStateFailed
case *samlrequest.SucceededEvent:
m.SAMLRequestState = domain.SAMLRequestStateSucceeded
}
}
return m.WriteModel.Reduce()
}
func (m *SAMLRequestWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(samlrequest.AggregateType).
AggregateIDs(m.AggregateID).
Builder()
}
// CheckAuthenticated checks that the auth request exists, a session must have been linked
func (m *SAMLRequestWriteModel) CheckAuthenticated() error {
if m.SessionID == "" {
return zerrors.ThrowPreconditionFailed(nil, "AUTHR-3dNRNwSYeC", "Errors.SAMLRequest.NotAuthenticated")
}
// check that the requests exists, but has not succeeded yet
if m.SAMLRequestState == domain.SAMLRequestStateAdded {
return nil
}
return zerrors.ThrowPreconditionFailed(nil, "AUTHR-krQV50AlnJ", "Errors.SAMLRequest.NotAuthenticated")
}

View File

@ -0,0 +1,676 @@
package command
import (
"context"
"net"
"net/http"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/samlrequest"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestCommands_AddSAMLRequest(t *testing.T) {
mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient")
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
ctx context.Context
request *SAMLRequest
}
tests := []struct {
name string
fields fields
args args
want *CurrentSAMLRequest
wantErr error
}{
{
"already exists error",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
),
idGenerator: mock.NewIDGeneratorExpectIDs(t, "id"),
},
args{
ctx: mockCtx,
request: &SAMLRequest{},
},
nil,
zerrors.ThrowPreconditionFailed(nil, "COMMAND-MO3vmsMLUt", "Errors.SAMLRequest.AlreadyExisting"),
},
{
"added",
fields{
eventstore: expectEventstore(
expectFilter(),
expectPush(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
idGenerator: mock.NewIDGeneratorExpectIDs(t, "id"),
},
args{
ctx: mockCtx,
request: &SAMLRequest{
LoginClient: "login",
ApplicationID: "application",
ACSURL: "acs",
RelayState: "relaystate",
RequestID: "request",
Binding: "binding",
Issuer: "issuer",
Destination: "destination",
},
},
&CurrentSAMLRequest{
SAMLRequest: &SAMLRequest{
ID: "V2_id",
LoginClient: "login",
ApplicationID: "application",
ACSURL: "acs",
RelayState: "relaystate",
RequestID: "request",
Binding: "binding",
Issuer: "issuer",
Destination: "destination",
},
},
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
}
got, err := c.AddSAMLRequest(tt.args.ctx, tt.args.request)
require.ErrorIs(t, tt.wantErr, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestCommands_LinkSessionToSAMLRequest(t *testing.T) {
mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient")
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
}
type args struct {
ctx context.Context
id string
sessionID string
sessionToken string
checkLoginClient bool
}
type res struct {
details *domain.ObjectDetails
authReq *CurrentSAMLRequest
wantErr error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"samlRequest not found",
fields{
eventstore: expectEventstore(
expectFilter(),
),
tokenVerifier: newMockTokenVerifierValid(),
},
args{
ctx: mockCtx,
id: "id",
sessionID: "sessionID",
},
res{
wantErr: zerrors.ThrowNotFound(nil, "COMMAND-GH3PVLSfXC", "Errors.SAMLRequest.NotExisting"),
},
},
{
"samlRequest not existing",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
eventFromEventPusher(
samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("id", "instanceID").Aggregate,
domain.SAMLErrorReasonUnspecified,
),
),
),
),
tokenVerifier: newMockTokenVerifierValid(),
},
args{
ctx: mockCtx,
id: "id",
sessionID: "sessionID",
},
res{
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ttPKNdAIFT", "Errors.SAMLRequest.AlreadyHandled"),
},
},
{
"wrong login client",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
),
tokenVerifier: newMockTokenVerifierValid(),
},
args{
ctx: authz.NewMockContext("instanceID", "orgID", "wrongLoginClient"),
id: "id",
sessionID: "sessionID",
sessionToken: "token",
checkLoginClient: true,
},
res{
wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient"),
},
},
{
"session not existing",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
expectFilter(),
),
tokenVerifier: newMockTokenVerifierValid(),
},
args{
ctx: mockCtx,
id: "V2_id",
sessionID: "sessionID",
},
res{
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"),
},
},
{
"session expired",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(mockCtx,
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
)),
eventFromEventPusher(
session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
"userID", "org1", testNow.Add(-5*time.Minute), &language.Afrikaans),
),
eventFromEventPusher(
session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
testNow.Add(-5*time.Minute)),
),
eventFromEventPusher(
session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
2*time.Minute),
),
),
),
},
args{
ctx: mockCtx,
id: "V2_id",
sessionID: "sessionID",
sessionToken: "token",
},
res{
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Hkl3d", "Errors.Session.Expired"),
},
},
{
"invalid session token",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(mockCtx,
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
)),
),
),
tokenVerifier: newMockTokenVerifierInvalid(),
},
args{
ctx: mockCtx,
id: "V2_id",
sessionID: "sessionID",
sessionToken: "invalid",
},
res{
wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"),
},
},
{
"linked",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(mockCtx,
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
)),
eventFromEventPusher(
session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
"userID", "org1", testNow, &language.Afrikaans),
),
eventFromEventPusher(
session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
testNow),
),
eventFromEventPusherWithCreationDateNow(
session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
2*time.Minute),
),
),
expectPush(
samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"sessionID",
"userID",
testNow,
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
),
),
),
tokenVerifier: newMockTokenVerifierValid(),
},
args{
ctx: mockCtx,
id: "V2_id",
sessionID: "sessionID",
sessionToken: "token",
},
res{
details: &domain.ObjectDetails{ResourceOwner: "instanceID"},
authReq: &CurrentSAMLRequest{
SAMLRequest: &SAMLRequest{
ID: "V2_id",
LoginClient: "login",
ApplicationID: "application",
ACSURL: "acs",
RelayState: "relaystate",
RequestID: "request",
Binding: "binding",
Issuer: "issuer",
Destination: "destination",
},
SessionID: "sessionID",
UserID: "userID",
AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
},
},
},
{
"linked with login client check",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"loginClient",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(mockCtx,
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
)),
eventFromEventPusher(
session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
"userID", "org1", testNow, &language.Afrikaans),
),
eventFromEventPusher(
session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
testNow),
),
eventFromEventPusherWithCreationDateNow(
session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate,
2*time.Minute),
),
),
expectPush(
samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"sessionID",
"userID",
testNow,
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
),
),
),
tokenVerifier: newMockTokenVerifierValid(),
},
args{
ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"),
id: "V2_id",
sessionID: "sessionID",
sessionToken: "token",
checkLoginClient: true,
},
res{
details: &domain.ObjectDetails{ResourceOwner: "instanceID"},
authReq: &CurrentSAMLRequest{
SAMLRequest: &SAMLRequest{
ID: "V2_id",
LoginClient: "loginClient",
ApplicationID: "application",
ACSURL: "acs",
RelayState: "relaystate",
RequestID: "request",
Binding: "binding",
Issuer: "issuer",
Destination: "destination",
},
SessionID: "sessionID",
UserID: "userID",
AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
sessionTokenVerifier: tt.fields.tokenVerifier,
}
details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient)
require.ErrorIs(t, err, tt.res.wantErr)
assertObjectDetails(t, tt.res.details, details)
if err == nil {
assert.WithinRange(t, got.AuthTime, testNow, testNow)
got.AuthTime = time.Time{}
}
assert.Equal(t, tt.res.authReq, got)
})
}
}
func TestCommands_FailSAMLRequest(t *testing.T) {
mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient")
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
id string
reason domain.SAMLErrorReason
description string
}
type res struct {
details *domain.ObjectDetails
samlReq *CurrentSAMLRequest
wantErr error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"authRequest not existing",
fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args{
ctx: mockCtx,
id: "foo",
reason: domain.SAMLErrorReasonAuthNFailed,
},
res{
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled"),
},
}, {
"already failed",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
domain.SAMLErrorReasonAuthNFailed,
),
),
),
},
args{
ctx: mockCtx,
id: "V2_id",
reason: domain.SAMLErrorReasonAuthNFailed,
description: "desc",
},
res{
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled"),
},
},
{
"failed",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
"login",
"application",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
expectPush(
samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate,
domain.SAMLErrorReasonAuthNFailed,
),
),
),
},
args{
ctx: mockCtx,
id: "V2_id",
reason: domain.SAMLErrorReasonAuthNFailed,
description: "desc",
},
res{
details: &domain.ObjectDetails{ResourceOwner: "instanceID"},
samlReq: &CurrentSAMLRequest{
SAMLRequest: &SAMLRequest{
ID: "V2_id",
LoginClient: "login",
ApplicationID: "application",
ACSURL: "acs",
RelayState: "relaystate",
RequestID: "request",
Binding: "binding",
Issuer: "issuer",
Destination: "destination",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
}
details, got, err := c.FailSAMLRequest(tt.args.ctx, tt.args.id, tt.args.reason)
require.ErrorIs(t, err, tt.res.wantErr)
assertObjectDetails(t, tt.res.details, details)
assert.Equal(t, tt.res.samlReq, got)
})
}
}

View File

@ -0,0 +1,186 @@
package command
import (
"context"
"time"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/activity"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/repository/samlrequest"
"github.com/zitadel/zitadel/internal/repository/samlsession"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type SAMLSession struct {
SessionID string
SAMLResponseID string
EntityID string
UserID string
Audience []string
Expiration time.Time
AuthMethods []domain.UserAuthMethodType
AuthTime time.Time
PreferredLanguage *language.Tag
UserAgent *domain.UserAgent
}
type SAMLRequestComplianceChecker func(context.Context, *SAMLRequestWriteModel) error
func (c *Commands) CreateSAMLSessionFromSAMLRequest(ctx context.Context, samlReqId string, complianceCheck SAMLRequestComplianceChecker, samlResponseID string, samlResponseLifetime time.Duration) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if samlReqId == "" {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-0LxK6O31wH", "Errors.SAMLRequest.InvalidCode")
}
samlReqModel, err := c.getSAMLRequestWriteModel(ctx, samlReqId)
if err != nil {
return err
}
instanceID := authz.GetInstance(ctx).InstanceID()
sessionModel := NewSessionWriteModel(samlReqModel.SessionID, instanceID)
err = c.eventstore.FilterToQueryReducer(ctx, sessionModel)
if err != nil {
return err
}
if err = sessionModel.CheckIsActive(); err != nil {
return err
}
cmd, err := c.newSAMLSessionAddEvents(ctx, sessionModel.UserID, sessionModel.UserResourceOwner)
if err != nil {
return err
}
if err = complianceCheck(ctx, samlReqModel); err != nil {
return err
}
cmd.AddSession(ctx,
sessionModel.UserID,
sessionModel.UserResourceOwner,
sessionModel.AggregateID,
samlReqModel.Issuer,
[]string{samlReqModel.Issuer},
samlReqModel.AuthMethods,
samlReqModel.AuthTime,
sessionModel.PreferredLanguage,
sessionModel.UserAgent,
)
if err = cmd.AddSAMLResponse(ctx, samlResponseID, samlResponseLifetime); err != nil {
return err
}
cmd.SetSAMLRequestSuccessful(ctx, samlReqModel.aggregate)
_, err = cmd.PushEvents(ctx)
return err
}
func (c *Commands) newSAMLSessionAddEvents(ctx context.Context, userID, resourceOwner string, pending ...eventstore.Command) (*SAMLSessionEvents, error) {
userStateModel, err := c.userStateWriteModel(ctx, userID)
if err != nil {
return nil, err
}
if !userStateModel.UserState.IsEnabled() {
return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-1768ZQpmcP", "Errors.User.NotActive")
}
sessionID, err := c.idGenerator.Next()
if err != nil {
return nil, err
}
sessionID = IDPrefixV2 + sessionID
return &SAMLSessionEvents{
commands: c,
idGenerator: c.idGenerator,
encryptionAlg: c.keyAlgorithm,
events: pending,
samlSessionWriteModel: NewSAMLSessionWriteModel(sessionID, resourceOwner),
userStateModel: userStateModel,
}, nil
}
type SAMLSessionEvents struct {
commands *Commands
idGenerator id.Generator
encryptionAlg crypto.EncryptionAlgorithm
events []eventstore.Command
samlSessionWriteModel *SAMLSessionWriteModel
userStateModel *UserV2WriteModel
// samlResponseID is set by the command
samlResponseID string
}
func (c *SAMLSessionEvents) AddSession(
ctx context.Context,
userID,
userResourceOwner,
sessionID,
entityID string,
audience []string,
authMethods []domain.UserAuthMethodType,
authTime time.Time,
preferredLanguage *language.Tag,
userAgent *domain.UserAgent,
) {
c.events = append(c.events, samlsession.NewAddedEvent(
ctx,
c.samlSessionWriteModel.aggregate,
userID,
userResourceOwner,
sessionID,
entityID,
audience,
authMethods,
authTime,
preferredLanguage,
userAgent,
))
}
func (c *SAMLSessionEvents) SetSAMLRequestSuccessful(ctx context.Context, samlRequestAggregate *eventstore.Aggregate) {
c.events = append(c.events, samlrequest.NewSucceededEvent(ctx, samlRequestAggregate))
}
func (c *SAMLSessionEvents) SetSAMLRequestFailed(ctx context.Context, samlRequestAggregate *eventstore.Aggregate, err domain.SAMLErrorReason) {
c.events = append(c.events, samlrequest.NewFailedEvent(ctx, samlRequestAggregate, err))
}
func (c *SAMLSessionEvents) AddSAMLResponse(ctx context.Context, id string, lifetime time.Duration) error {
c.samlResponseID = id
c.events = append(c.events, samlsession.NewSAMLResponseAddedEvent(ctx, c.samlSessionWriteModel.aggregate, id, lifetime))
return nil
}
func (c *SAMLSessionEvents) PushEvents(ctx context.Context) (*SAMLSession, error) {
pushedEvents, err := c.commands.eventstore.Push(ctx, c.events...)
if err != nil {
return nil, err
}
err = AppendAndReduce(c.samlSessionWriteModel, pushedEvents...)
if err != nil {
return nil, err
}
session := &SAMLSession{
SessionID: c.samlSessionWriteModel.SessionID,
EntityID: c.samlSessionWriteModel.EntityID,
UserID: c.samlSessionWriteModel.UserID,
Audience: c.samlSessionWriteModel.Audience,
Expiration: c.samlSessionWriteModel.SAMLResponseExpiration,
AuthMethods: c.samlSessionWriteModel.AuthMethods,
AuthTime: c.samlSessionWriteModel.AuthTime,
PreferredLanguage: c.samlSessionWriteModel.PreferredLanguage,
UserAgent: c.samlSessionWriteModel.UserAgent,
SAMLResponseID: c.samlSessionWriteModel.SAMLResponseID,
}
activity.Trigger(ctx, c.samlSessionWriteModel.UserResourceOwner, c.samlSessionWriteModel.UserID, activity.SAMLResponse, c.commands.eventstore.FilterToQueryReducer)
return session, nil
}

View File

@ -0,0 +1,102 @@
package command
import (
"time"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/samlsession"
)
type SAMLSessionWriteModel struct {
eventstore.WriteModel
UserID string
UserResourceOwner string
PreferredLanguage *language.Tag
SessionID string
EntityID string
Audience []string
AuthMethods []domain.UserAuthMethodType
AuthTime time.Time
UserAgent *domain.UserAgent
State domain.SAMLSessionState
SAMLResponseID string
SAMLResponseCreation time.Time
SAMLResponseExpiration time.Time
aggregate *eventstore.Aggregate
}
func NewSAMLSessionWriteModel(id string, resourceOwner string) *SAMLSessionWriteModel {
return &SAMLSessionWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: id,
ResourceOwner: resourceOwner,
},
aggregate: &samlsession.NewAggregate(id, resourceOwner).Aggregate,
}
}
func (wm *SAMLSessionWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *samlsession.AddedEvent:
wm.reduceAdded(e)
case *samlsession.SAMLResponseAddedEvent:
wm.reduceSAMLResponseAdded(e)
case *samlsession.SAMLResponseRevokedEvent:
wm.reduceSAMLResponseRevoked(e)
}
}
return wm.WriteModel.Reduce()
}
func (wm *SAMLSessionWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(samlsession.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
samlsession.AddedType,
samlsession.SAMLResponseAddedType,
samlsession.SAMLResponseRevokedType,
).
Builder()
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
return query
}
func (wm *SAMLSessionWriteModel) reduceAdded(e *samlsession.AddedEvent) {
wm.UserID = e.UserID
wm.UserResourceOwner = e.UserResourceOwner
wm.SessionID = e.SessionID
wm.EntityID = e.EntityID
wm.Audience = e.Audience
wm.AuthMethods = e.AuthMethods
wm.AuthTime = e.AuthTime
wm.PreferredLanguage = e.PreferredLanguage
wm.UserAgent = e.UserAgent
wm.State = domain.SAMLSessionStateActive
// the write model might be initialized without resource owner,
// so update the aggregate
if wm.ResourceOwner == "" {
wm.aggregate = &samlsession.NewAggregate(wm.AggregateID, e.Aggregate().ResourceOwner).Aggregate
}
}
func (wm *SAMLSessionWriteModel) reduceSAMLResponseAdded(e *samlsession.SAMLResponseAddedEvent) {
wm.SAMLResponseID = e.ID
wm.SAMLResponseCreation = e.CreationDate()
wm.SAMLResponseExpiration = e.CreationDate().Add(e.Lifetime)
}
func (wm *SAMLSessionWriteModel) reduceSAMLResponseRevoked(e *samlsession.SAMLResponseRevokedEvent) {
wm.SAMLResponseID = ""
wm.SAMLResponseExpiration = e.CreationDate()
}

View File

@ -0,0 +1,337 @@
package command
import (
"context"
"io"
"net"
"net/http"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/samlrequest"
"github.com/zitadel/zitadel/internal/repository/samlsession"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
func mockSAMLRequestComplianceChecker(returnErr error) SAMLRequestComplianceChecker {
return func(context.Context, *SAMLRequestWriteModel) error {
return returnErr
}
}
func TestCommands_CreateSAMLSessionFromSAMLRequest(t *testing.T) {
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
idGenerator id.Generator
keyAlgorithm crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
samlRequestID string
samlResponseID string
complianceCheck SAMLRequestComplianceChecker
samlResponseLifetime time.Duration
}
type res struct {
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"missing code",
fields{
eventstore: expectEventstore(),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
samlRequestID: "",
complianceCheck: mockSAMLRequestComplianceChecker(nil),
},
res{
err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-0LxK6O31wH", "Errors.SAMLRequest.InvalidCode"),
},
},
{
"filter error",
fields{
eventstore: expectEventstore(
expectFilterError(io.ErrClosedPipe),
),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
samlRequestID: "V2_samlRequestID",
complianceCheck: mockSAMLRequestComplianceChecker(nil),
},
res{
err: io.ErrClosedPipe,
},
},
{
"session filter error",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate,
"loginClient",
"applicationId",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
),
expectFilterError(io.ErrClosedPipe),
),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
samlRequestID: "V2_samlRequestID",
complianceCheck: mockSAMLRequestComplianceChecker(nil),
},
res{
err: io.ErrClosedPipe,
},
},
{
"inactive session error",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate,
"loginClient",
"applicationId",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
eventFromEventPusher(
samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate,
"sessionID",
"userID",
testNow,
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
),
),
),
expectFilter(), // inactive session
),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
samlRequestID: "V2_samlRequestID",
complianceCheck: mockSAMLRequestComplianceChecker(nil),
},
res{
err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"),
},
},
{
"user not active",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate,
"loginClient",
"applicationId",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
eventFromEventPusher(
samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate,
"sessionID",
"userID",
testNow,
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
),
),
),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(),
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
),
eventFromEventPusher(
session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
"userID", "org1", testNow, &language.Afrikaans),
),
eventFromEventPusher(
session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
testNow),
),
),
expectFilter(
user.NewHumanAddedEvent(
context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
user.NewUserDeactivatedEvent(
context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
),
),
),
idGenerator: mock.NewIDGeneratorExpectIDs(t),
keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
samlRequestID: "V2_samlRequestID",
samlResponseID: "samlResponseID",
samlResponseLifetime: time.Minute * 5,
complianceCheck: mockSAMLRequestComplianceChecker(nil),
},
res{
err: zerrors.ThrowPreconditionFailed(nil, "SAML-1768ZQpmcP", "Errors.User.NotActive"),
},
},
{
"add successful",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate,
"loginClient",
"applicationId",
"acs",
"relaystate",
"request",
"binding",
"issuer",
"destination",
),
),
eventFromEventPusher(
samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate,
"sessionID",
"userID",
testNow,
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
),
),
),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(),
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
),
eventFromEventPusher(
session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
"userID", "org1", testNow, &language.Afrikaans),
),
eventFromEventPusher(
session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
testNow),
),
),
expectFilter(
user.NewHumanAddedEvent(
context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
),
expectPush(
samlsession.NewAddedEvent(context.Background(), &samlsession.NewAggregate("V2_samlSessionID", "org1").Aggregate,
"userID", "org1", "sessionID", "issuer", []string{"issuer"},
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, &language.Afrikaans,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
samlsession.NewSAMLResponseAddedEvent(context.Background(), &samlsession.NewAggregate("V2_samlSessionID", "org1").Aggregate, "samlResponseID", time.Minute*5),
samlrequest.NewSucceededEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate),
),
),
idGenerator: mock.NewIDGeneratorExpectIDs(t, "samlSessionID"),
keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
samlRequestID: "V2_samlRequestID",
samlResponseID: "samlResponseID",
samlResponseLifetime: time.Minute * 5,
complianceCheck: mockSAMLRequestComplianceChecker(nil),
},
res{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
keyAlgorithm: tt.fields.keyAlgorithm,
}
err := c.CreateSAMLSessionFromSAMLRequest(tt.args.ctx, tt.args.samlRequestID, tt.args.complianceCheck, tt.args.samlResponseID, tt.args.samlResponseLifetime)
require.ErrorIs(t, err, tt.res.err)
})
}
}

View File

@ -0,0 +1,41 @@
package domain
import (
"github.com/zitadel/saml/pkg/provider"
)
type SAMLErrorReason int32
const (
SAMLErrorReasonUnspecified SAMLErrorReason = iota
SAMLErrorReasonVersionMissmatch
SAMLErrorReasonAuthNFailed
SAMLErrorReasonInvalidAttrNameOrValue
SAMLErrorReasonInvalidNameIDPolicy
SAMLErrorReasonRequestDenied
SAMLErrorReasonRequestUnsupported
SAMLErrorReasonUnsupportedBinding
)
func SAMLErrorReasonToString(reason SAMLErrorReason) string {
switch reason {
case SAMLErrorReasonUnspecified:
return "unspecified error"
case SAMLErrorReasonVersionMissmatch:
return provider.StatusCodeVersionMissmatch
case SAMLErrorReasonAuthNFailed:
return provider.StatusCodeAuthNFailed
case SAMLErrorReasonInvalidAttrNameOrValue:
return provider.StatusCodeInvalidAttrNameOrValue
case SAMLErrorReasonInvalidNameIDPolicy:
return provider.StatusCodeInvalidNameIDPolicy
case SAMLErrorReasonRequestDenied:
return provider.StatusCodeRequestDenied
case SAMLErrorReasonRequestUnsupported:
return provider.StatusCodeRequestUnsupported
case SAMLErrorReasonUnsupportedBinding:
return provider.StatusCodeUnsupportedBinding
default:
return "unspecified error"
}
}

View File

@ -0,0 +1,10 @@
package domain
type SAMLRequestState int
const (
SAMLRequestStateUnspecified SAMLRequestState = iota
SAMLRequestStateAdded
SAMLRequestStateFailed
SAMLRequestStateSucceeded
)

View File

@ -0,0 +1,9 @@
package domain
type SAMLSessionState int32
const (
SAMLSessionStateUnspecified SAMLSessionState = iota
SAMLSessionStateActive
SAMLSessionStateTerminated
)

View File

@ -34,6 +34,7 @@ import (
user_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha"
userschema_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/userschema/v3alpha"
webkey_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha"
saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
session_v2beta "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2"
@ -65,6 +66,7 @@ type Client struct {
WebKeyV3Alpha webkey_v3alpha.ZITADELWebKeysClient
IDPv2 idp_pb.IdentityProviderServiceClient
UserV3Alpha user_v3alpha.ZITADELUsersClient
SAMLv2 saml_pb.SAMLServiceClient
}
func newClient(ctx context.Context, target string) (*Client, error) {
@ -96,6 +98,7 @@ func newClient(ctx context.Context, target string) (*Client, error) {
WebKeyV3Alpha: webkey_v3alpha.NewZITADELWebKeysClient(cc),
IDPv2: idp_pb.NewIdentityProviderServiceClient(cc),
UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc),
SAMLv2: saml_pb.NewSAMLServiceClient(cc),
}
return client, client.pollHealth(ctx)
}

View File

@ -0,0 +1,223 @@
package integration
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/brianvoe/gofakeit/v6"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"github.com/zitadel/logging"
http_util "github.com/zitadel/zitadel/internal/api/http"
oidc_internal "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/pkg/grpc/management"
saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2"
session_pb "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
const spCertificate = `-----BEGIN CERTIFICATE-----
MIIDITCCAgmgAwIBAgIUUo5urYkuUHAe7LQ9sZSL+xXAqBwwDQYJKoZIhvcNAQEL
BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIwNDEz
MTE1MFoXDTI1MDEwMzEzMTE1MFowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w
bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoACwbGIh8udK
Um1r+yQoPtfswEX6Cb6Y1KwR6WZDYgzHdMyUC5Sy8Bg1H2puUZZukDLuyu6Pqvum
8kfnzhjUR6nNCoUlidwE+yz020w5oOBofRKgJK/FVUuWD3k6kjdP9CrBFLG0PQQ3
N2e4wilP4czCxizKero2a0e7Eq8OjHAPf8gjM+GWFZgVAbV8uf2Mjt1O2Vfbx5PZ
sLuBZtl5jokx3NiC7my/yj81MbGEDPcQo0emeVBz3J3nVG6Yr4kdCKkvv2dhJ26C
5cL7NIIUY4IQomJNwYC2NaYgSpQOxJHL/HsOPusO4Ia2WtUTXEZUFkxn1u0YuoSx
CkGehF/1OwIDAQABo1MwUTAdBgNVHQ4EFgQUr6S0wA2l3MdfnvfveWDueQtaoJMw
HwYDVR0jBBgwFoAUr6S0wA2l3MdfnvfveWDueQtaoJMwDwYDVR0TAQH/BAUwAwEB
/zANBgkqhkiG9w0BAQsFAAOCAQEAH3Q9obyWJaMKFuGJDkIp1RFot79RWTVcAcwA
XTJNfCseLONRIs4MkRxOn6GQBwV2IEqs1+hFG80dcd/c6yYyJ8bziKEyNMtPWrl6
fdVD+1WnWcD1ZYrS8hgdz0FxXxl/+GjA8Pu6icmnhKgUDTYWns6Rj/gtQtZS8ZoA
JY+T/1mGze2+Xx6pjuArZ7+hnH6EWwo+ckcmXAKyhnkhX7xIo1UFvNY2VWaGl2wU
K2yyJA4Lu/NNmqPnpAcRDsnGP6r4frMhjnPq/ifC3B+6FT3p8dubV9PA0y86bAy5
0yIgNje4DyWLy/DM9EpdPfJmvUAL6hOtyb8Aa9hR+a8stu7h6g==
-----END CERTIFICATE-----`
const spKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCgALBsYiHy50pS
bWv7JCg+1+zARfoJvpjUrBHpZkNiDMd0zJQLlLLwGDUfam5Rlm6QMu7K7o+q+6by
R+fOGNRHqc0KhSWJ3AT7LPTbTDmg4Gh9EqAkr8VVS5YPeTqSN0/0KsEUsbQ9BDc3
Z7jCKU/hzMLGLMp6ujZrR7sSrw6McA9/yCMz4ZYVmBUBtXy5/YyO3U7ZV9vHk9mw
u4Fm2XmOiTHc2ILubL/KPzUxsYQM9xCjR6Z5UHPcnedUbpiviR0IqS+/Z2EnboLl
wvs0ghRjghCiYk3BgLY1piBKlA7Ekcv8ew4+6w7ghrZa1RNcRlQWTGfW7Ri6hLEK
QZ6EX/U7AgMBAAECggEAD1aRkwpDO+BdORKhP9WDACc93F647fc1+mk2XFv/yKX1
9uXnqUaLcsW3TfgrdCnKFouzZYPCBP+TzPUErTanHumRrNj/tLwBRDzWijE/8wKg
MaE39dxdu+P/kiMqcLrZsMvqb3vrjc/aJTcNuJsyO7Cf2VSQ4nv4XIdnUQ60A9VR
OmUp//VULZxImnPx/R304/p5VfOhyXfzBeoxUPogBurjtzkyXVG0EG2enJMMiTix
900fTDez0TQ8V6O59vM04fhtPXvH51OkMTW/HU1QQvlnAJuX06I7k4CaBpF3xPII
QpEbFILq5y6yAQJWELRGWzeoxK6kn6bNfI8S0+oKqQKBgQDg2UM7ruMASpY7B4zj
XNztGDOx9BCdYyHH1O05r+ILmltBC7jFImwIYrHbaX+dg52l0PPImZuBz852IqrC
VAEF30yBn2gWyVzIdo7W3mw9Jgqc4LrhStaJxOuXVoT2/PAuDBF8TJMNH9oLNqiD
aPAI0cVn9BRV7AziEsrMlDLLiQKBgQC2K4Z/caAvwx/AescsN6lp+/m7MeLUpZzQ
myZt44bnR5LouUo3vCYl+Bk8wu6PTd41LUYW/SW26HDDFTKgkBb1zVHfk5QRApaB
VPwZnhcUvNapPOnDp75Qoq238wpfayQlKF1xCawS3N5AWkDaEdfzuH7umFJxVss2
1tfDsn01owKBgAYWG3nMHBzv5+0lIS0uYFSSqSOSBbkc69cq7lj3Z9kEjp/OH2xG
qEH52fKkgm3TGDta0p6Fee4jn+UWvySPfY+ZIcsIc5raTIaonuk2EBv/oZ3pf2WF
zxTfnbj1AJhm9GFqtjZ1JC3gxNg03I7iEk1K0FsmAj7pKtgbxh2PjWhxAoGBAKBx
BSwJbwOh3r0vZWvUOilV+0SbUyPmGI7Blr8BvTbFGuZNCsi7tP2L3O5e4Kzl7+b1
0N0+Z5EIdwfaC5TOUup5wroeyDGTDesqZj5JthpVltnHBDuF6WArZsS0EVaojlUL
kACWfC7AyB31X1iwjnng7CpHjZS01JWf8rgw44XxAoGAQ5YYd4WmGYZoJJak7zhb
xnYG7hU7nS7pBPGob1FvjYMw1x/htuJCjxLh08dlzJGM6SFlDn7HVM9ou99w5n+d
xtqmbthw2E9VjSk3zSYb4uFc6mv0C/kRPTDUFH+9CpQTBBx/O016hmcatxlBS6JL
VAV6oE8sEJYHtR6YdZiMWWo=
-----END PRIVATE KEY-----`
func CreateSAMLSP(root string, idpMetadata *saml.EntityDescriptor, binding string) (*samlsp.Middleware, error) {
rootURL, err := url.Parse(root)
if err != nil {
return nil, err
}
keyPair, err := tls.X509KeyPair([]byte(spCertificate), []byte(spKey))
if err != nil {
return nil, err
}
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
if err != nil {
return nil, err
}
sp, err := samlsp.New(samlsp.Options{
URL: *rootURL,
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
Certificate: keyPair.Leaf,
IDPMetadata: idpMetadata,
UseArtifactResponse: false,
})
if err != nil {
return nil, err
}
sp.Binding = binding
sp.ResponseBinding = binding
return sp, nil
}
func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *samlsp.Middleware) (*management.AddSAMLAppResponse, error) {
spMetadata, err := xml.MarshalIndent(m.ServiceProvider.Metadata(), "", " ")
if err != nil {
return nil, err
}
if m.ResponseBinding == saml.HTTPRedirectBinding {
metadata := strings.Replace(string(spMetadata), saml.HTTPPostBinding, saml.HTTPRedirectBinding, 2)
spMetadata = []byte(metadata)
}
resp, err := i.Client.Mgmt.AddSAMLApp(ctx, &management.AddSAMLAppRequest{
ProjectId: projectID,
Name: fmt.Sprintf("app-%s", gofakeit.AppName()),
Metadata: &management.AddSAMLAppRequest_MetadataXml{MetadataXml: spMetadata},
})
if err != nil {
return nil, err
}
return resp, await(func() error {
_, err := i.Client.Mgmt.GetProjectByID(ctx, &management.GetProjectByIDRequest{
Id: projectID,
})
if err != nil {
return err
}
_, err = i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{
ProjectId: projectID,
AppId: resp.GetAppId(),
})
return err
})
}
func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (authRequestID string, err error) {
authReq, err := m.ServiceProvider.MakeAuthenticationRequest(acs.Location, acs.Binding, responseBinding)
if err != nil {
return "", err
}
redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider)
if err != nil {
return "", err
}
req, err := GetRequest(redirectURL.String(), map[string]string{oidc_internal.LoginClientHeader: loginClient})
if err != nil {
return "", fmt.Errorf("get request: %w", err)
}
loc, err := CheckRedirect(req)
if err != nil {
return "", fmt.Errorf("check redirect: %w", err)
}
prefixWithHost := i.Issuer() + i.Config.LoginURLV2
if !strings.HasPrefix(loc.String(), prefixWithHost) {
return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String())
}
return strings.TrimPrefix(loc.String(), prefixWithHost), nil
}
func (i *Instance) FailSAMLAuthRequest(ctx context.Context, id string, reason saml_pb.ErrorReason) *saml_pb.CreateResponseResponse {
resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{
SamlRequestId: id,
ResponseKind: &saml_pb.CreateResponseRequest_Error{Error: &saml_pb.AuthorizationError{Error: reason}},
})
logging.OnError(err).Panic("create human user")
return resp
}
func (i *Instance) SuccessfulSAMLAuthRequest(ctx context.Context, userId, id string) *saml_pb.CreateResponseResponse {
respSession, err := i.Client.SessionV2.CreateSession(ctx, &session_pb.CreateSessionRequest{
Checks: &session_pb.Checks{
User: &session_pb.CheckUser{
Search: &session_pb.CheckUser_UserId{
UserId: userId,
},
},
},
})
logging.OnError(err).Panic("create session")
resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{
SamlRequestId: id,
ResponseKind: &saml_pb.CreateResponseRequest_Session{
Session: &saml_pb.Session{
SessionId: respSession.GetSessionId(),
SessionToken: respSession.GetSessionToken(),
},
},
})
logging.OnError(err).Panic("create human user")
return resp
}
func (i *Instance) GetSAMLIDPMetadata() (*saml.EntityDescriptor, error) {
idpEntityID := http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure) + "/saml/v2/metadata"
resp, err := http.Get(idpEntityID)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
entityDescriptor := new(saml.EntityDescriptor)
if err := xml.Unmarshal(data, entityDescriptor); err != nil {
return nil, err
}
return entityDescriptor, nil
}
func (i *Instance) Issuer() string {
return http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure)
}

View File

@ -69,6 +69,7 @@ var (
DeviceAuthProjection *handler.Handler
SessionProjection *handler.Handler
AuthRequestProjection *handler.Handler
SamlRequestProjection *handler.Handler
MilestoneProjection *handler.Handler
QuotaProjection *quotaProjection
LimitsProjection *handler.Handler
@ -157,6 +158,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore,
DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"]))
SessionProjection = newSessionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["sessions"]))
AuthRequestProjection = newAuthRequestProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["auth_requests"]))
SamlRequestProjection = newSamlRequestProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["saml_requests"]))
MilestoneProjection = newMilestoneProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["milestones"]))
QuotaProjection = newQuotaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["quotas"]))
LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"]))
@ -286,6 +288,7 @@ func newProjectionsList() {
DeviceAuthProjection,
SessionProjection,
AuthRequestProjection,
SamlRequestProjection,
MilestoneProjection,
QuotaProjection.handler,
LimitsProjection,

View File

@ -0,0 +1,132 @@
package projection
import (
"context"
"github.com/zitadel/zitadel/internal/eventstore"
old_handler "github.com/zitadel/zitadel/internal/eventstore/handler"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/samlrequest"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
SamlRequestsProjectionTable = "projections.saml_requests"
SamlRequestColumnID = "id"
SamlRequestColumnCreationDate = "creation_date"
SamlRequestColumnChangeDate = "change_date"
SamlRequestColumnSequence = "sequence"
SamlRequestColumnResourceOwner = "resource_owner"
SamlRequestColumnInstanceID = "instance_id"
SamlRequestColumnLoginClient = "login_client"
SamlRequestColumnIssuer = "issuer"
SamlRequestColumnACS = "acs"
SamlRequestColumnRelayState = "relay_state"
SamlRequestColumnBinding = "binding"
)
type samlRequestProjection struct{}
// Name implements handler.Projection.
func (*samlRequestProjection) Name() string {
return SamlRequestsProjectionTable
}
func newSamlRequestProjection(ctx context.Context, config handler.Config) *handler.Handler {
return handler.NewHandler(ctx, &config, new(samlRequestProjection))
}
func (*samlRequestProjection) Init() *old_handler.Check {
return handler.NewMultiTableCheck(
handler.NewTable([]*handler.InitColumn{
handler.NewColumn(SamlRequestColumnID, handler.ColumnTypeText),
handler.NewColumn(SamlRequestColumnCreationDate, handler.ColumnTypeTimestamp),
handler.NewColumn(SamlRequestColumnChangeDate, handler.ColumnTypeTimestamp),
handler.NewColumn(SamlRequestColumnSequence, handler.ColumnTypeInt64),
handler.NewColumn(SamlRequestColumnResourceOwner, handler.ColumnTypeText),
handler.NewColumn(SamlRequestColumnInstanceID, handler.ColumnTypeText),
handler.NewColumn(SamlRequestColumnLoginClient, handler.ColumnTypeText),
handler.NewColumn(SamlRequestColumnIssuer, handler.ColumnTypeText),
handler.NewColumn(SamlRequestColumnACS, handler.ColumnTypeText),
handler.NewColumn(SamlRequestColumnRelayState, handler.ColumnTypeText),
handler.NewColumn(SamlRequestColumnBinding, handler.ColumnTypeText),
},
handler.NewPrimaryKey(SamlRequestColumnInstanceID, SamlRequestColumnID),
),
)
}
func (p *samlRequestProjection) Reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{
{
Aggregate: samlrequest.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: samlrequest.AddedType,
Reduce: p.reduceSamlRequestAdded,
},
{
Event: samlrequest.SucceededType,
Reduce: p.reduceSamlRequestEnded,
},
{
Event: samlrequest.FailedType,
Reduce: p.reduceSamlRequestEnded,
},
},
},
{
Aggregate: instance.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: instance.InstanceRemovedEventType,
Reduce: reduceInstanceRemovedHelper(SamlRequestColumnInstanceID),
},
},
},
}
}
func (p *samlRequestProjection) reduceSamlRequestAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*samlrequest.AddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sfwfa", "reduce.wrong.event.type %s", samlrequest.AddedType)
}
return handler.NewCreateStatement(
e,
[]handler.Column{
handler.NewCol(SamlRequestColumnID, e.Aggregate().ID),
handler.NewCol(SamlRequestColumnInstanceID, e.Aggregate().InstanceID),
handler.NewCol(SamlRequestColumnCreationDate, e.CreationDate()),
handler.NewCol(SamlRequestColumnChangeDate, e.CreationDate()),
handler.NewCol(SamlRequestColumnResourceOwner, e.Aggregate().ResourceOwner),
handler.NewCol(SamlRequestColumnSequence, e.Sequence()),
handler.NewCol(SamlRequestColumnLoginClient, e.LoginClient),
handler.NewCol(SamlRequestColumnIssuer, e.Issuer),
handler.NewCol(SamlRequestColumnACS, e.ACSURL),
handler.NewCol(SamlRequestColumnRelayState, e.RelayState),
handler.NewCol(SamlRequestColumnBinding, e.Binding),
},
), nil
}
func (p *samlRequestProjection) reduceSamlRequestEnded(event eventstore.Event) (*handler.Statement, error) {
switch event.(type) {
case *samlrequest.SucceededEvent,
*samlrequest.FailedEvent:
break
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3h", "reduce.wrong.event.type %s", []eventstore.EventType{samlrequest.SucceededType, samlrequest.FailedType})
}
return handler.NewDeleteStatement(
event,
[]handler.Condition{
handler.NewCond(SamlRequestColumnID, event.Aggregate().ID),
handler.NewCond(SamlRequestColumnInstanceID, event.Aggregate().InstanceID),
},
), nil
}

View File

@ -0,0 +1,123 @@
package projection
import (
"testing"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/repository/samlrequest"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestSamlRequestProjection_reduces(t *testing.T) {
type args struct {
event func(t *testing.T) eventstore.Event
}
tests := []struct {
name string
args args
reduce func(event eventstore.Event) (*handler.Statement, error)
want wantReduce
}{
{
name: "reduceSamlRequestAdded",
args: args{
event: getEvent(testEvent(
samlrequest.AddedType,
samlrequest.AggregateType,
[]byte(`{"login_client": "loginClient", "issuer": "issuer", "acs_url": "acs", "relay_state": "relayState", "binding": "binding"}`),
), eventstore.GenericEventMapper[samlrequest.AddedEvent]),
},
reduce: (&samlRequestProjection{}).reduceSamlRequestAdded,
want: wantReduce{
aggregateType: eventstore.AggregateType("saml_request"),
sequence: 15,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.saml_requests (id, instance_id, creation_date, change_date, resource_owner, sequence, login_client, issuer, acs, relay_state, binding) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
anyArg{},
anyArg{},
"ro-id",
uint64(15),
"loginClient",
"issuer",
"acs",
"relayState",
"binding",
},
},
},
},
},
},
{
name: "reduceSamlRequestFailed",
args: args{
event: getEvent(testEvent(
samlrequest.FailedType,
samlrequest.AggregateType,
[]byte(`{"reason": 0}`),
), eventstore.GenericEventMapper[samlrequest.FailedEvent]),
},
reduce: (&samlRequestProjection{}).reduceSamlRequestEnded,
want: wantReduce{
aggregateType: eventstore.AggregateType("saml_request"),
sequence: 15,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.saml_requests WHERE (id = $1) AND (instance_id = $2)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
},
},
},
},
},
},
{
name: "reduceSamlRequestSucceeded",
args: args{
event: getEvent(testEvent(
samlrequest.SucceededType,
samlrequest.AggregateType,
nil,
), eventstore.GenericEventMapper[samlrequest.SucceededEvent]),
},
reduce: (&samlRequestProjection{}).reduceSamlRequestEnded,
want: wantReduce{
aggregateType: eventstore.AggregateType("saml_request"),
sequence: 15,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.saml_requests WHERE (id = $1) AND (instance_id = $2)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := baseEvent(t)
got, err := tt.reduce(event)
if !zerrors.IsErrorInvalidArgument(err) {
t.Errorf("no wrong event mapping: %v, got: %v", err, got)
}
event = tt.args.event(t)
got, err = tt.reduce(event)
assertReduce(t, got, err, SamlRequestsProjectionTable, tt.want)
})
}
}

View File

@ -0,0 +1,81 @@
package query
import (
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/call"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type SamlRequest struct {
ID string
CreationDate time.Time
LoginClient string
Issuer string
ACS string
RelayState string
Binding string
}
func (a *SamlRequest) checkLoginClient(ctx context.Context) error {
if uid := authz.GetCtxData(ctx).UserID; uid != a.LoginClient {
return zerrors.ThrowPermissionDenied(nil, "OIDCv2-aL0ag", "Errors.SamlRequest.WrongLoginClient")
}
return nil
}
//go:embed saml_request_by_id.sql
var samlRequestByIDQuery string
func (q *Queries) samlRequestByIDQuery(ctx context.Context) string {
return fmt.Sprintf(samlRequestByIDQuery, q.client.Timetravel(call.Took(ctx)))
}
func (q *Queries) SamlRequestByID(ctx context.Context, shouldTriggerBulk bool, id string, checkLoginClient bool) (_ *SamlRequest, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if shouldTriggerBulk {
_, traceSpan := tracing.NewNamedSpan(ctx, "TriggerSamlRequestProjection")
ctx, err = projection.SamlRequestProjection.Trigger(ctx, handler.WithAwaitRunning())
logging.OnError(err).Debug("trigger failed")
traceSpan.EndWithError(err)
}
dst := new(SamlRequest)
err = q.client.QueryRowContext(
ctx,
func(row *sql.Row) error {
return row.Scan(
&dst.ID, &dst.CreationDate, &dst.LoginClient, &dst.Issuer, &dst.ACS, &dst.RelayState, &dst.Binding,
)
},
q.samlRequestByIDQuery(ctx),
id, authz.GetInstance(ctx).InstanceID(),
)
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowNotFound(err, "QUERY-Thee9", "Errors.SamlRequest.NotExisting")
}
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-Ou8ue", "Errors.Internal")
}
if checkLoginClient {
if err = dst.checkLoginClient(ctx); err != nil {
return nil, err
}
}
return dst, nil
}

View File

@ -0,0 +1,11 @@
select
id,
creation_date,
login_client,
issuer,
acs,
relay_state,
binding
from projections.saml_requests %s
where id = $1 and instance_id = $2
limit 1;

View File

@ -0,0 +1,127 @@
package query
import (
"database/sql"
"database/sql/driver"
_ "embed"
"fmt"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestQueries_SamlRequestByID(t *testing.T) {
expQuery := regexp.QuoteMeta(fmt.Sprintf(
samlRequestByIDQuery,
asOfSystemTime,
))
cols := []string{
projection.SamlRequestColumnID,
projection.SamlRequestColumnCreationDate,
projection.SamlRequestColumnLoginClient,
projection.SamlRequestColumnIssuer,
projection.SamlRequestColumnACS,
projection.SamlRequestColumnRelayState,
projection.SamlRequestColumnBinding,
}
type args struct {
shouldTriggerBulk bool
id string
checkLoginClient bool
}
tests := []struct {
name string
args args
expect sqlExpectation
want *SamlRequest
wantErr error
}{
{
name: "success, all values",
args: args{
shouldTriggerBulk: false,
id: "123",
checkLoginClient: true,
},
expect: mockQuery(expQuery, cols, []driver.Value{
"id",
testNow,
"loginClient",
"issuer",
"acs",
"relayState",
"binding",
}, "123", "instanceID"),
want: &SamlRequest{
ID: "id",
CreationDate: testNow,
LoginClient: "loginClient",
Issuer: "issuer",
ACS: "acs",
RelayState: "relayState",
Binding: "binding",
},
},
{
name: "no rows",
args: args{
shouldTriggerBulk: false,
id: "123",
},
expect: mockQueryScanErr(expQuery, cols, nil, "123", "instanceID"),
wantErr: zerrors.ThrowNotFound(sql.ErrNoRows, "QUERY-Thee9", "Errors.SamlRequest.NotExisting"),
},
{
name: "query error",
args: args{
shouldTriggerBulk: false,
id: "123",
},
expect: mockQueryErr(expQuery, sql.ErrConnDone, "123", "instanceID"),
wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Ou8ue", "Errors.Internal"),
},
{
name: "wrong login client",
args: args{
shouldTriggerBulk: false,
id: "123",
checkLoginClient: true,
},
expect: mockQuery(expQuery, cols, []driver.Value{
"id",
testNow,
"wrongLoginClient",
"issuer",
"acs",
"relayState",
"binding",
}, "123", "instanceID"),
wantErr: zerrors.ThrowPermissionDeniedf(nil, "OIDCv2-aL0ag", "Errors.SamlRequest.WrongLoginClient"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
execMock(t, tt.expect, func(db *sql.DB) {
q := &Queries{
client: &database.DB{
DB: db,
Database: &prepareDB{},
},
}
ctx := authz.NewMockContext("instanceID", "orgID", "loginClient")
got, err := q.SamlRequestByID(ctx, tt.args.shouldTriggerBulk, tt.args.id, tt.args.checkLoginClient)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
})
}
}

View File

@ -0,0 +1,26 @@
package samlrequest
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
AggregateType = "saml_request"
AggregateVersion = "v1"
)
type Aggregate struct {
eventstore.Aggregate
}
func NewAggregate(id, instanceID string) *Aggregate {
return &Aggregate{
Aggregate: eventstore.Aggregate{
Type: AggregateType,
Version: AggregateVersion,
ID: id,
ResourceOwner: instanceID,
InstanceID: instanceID,
},
}
}

View File

@ -0,0 +1,10 @@
package samlrequest
import "github.com/zitadel/zitadel/internal/eventstore"
func init() {
eventstore.RegisterFilterEventMapper(AggregateType, AddedType, eventstore.GenericEventMapper[AddedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, SessionLinkedType, eventstore.GenericEventMapper[SessionLinkedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, FailedType, eventstore.GenericEventMapper[FailedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, SucceededType, eventstore.GenericEventMapper[SucceededEvent])
}

View File

@ -0,0 +1,172 @@
package samlrequest
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
samlRequestEventPrefix = "saml_request."
AddedType = samlRequestEventPrefix + "added"
FailedType = samlRequestEventPrefix + "failed"
SessionLinkedType = samlRequestEventPrefix + "session.linked"
SucceededType = samlRequestEventPrefix + "succeeded"
)
type AddedEvent struct {
*eventstore.BaseEvent `json:"-"`
LoginClient string `json:"login_client,omitempty"`
ApplicationID string `json:"application_id,omitempty"`
ACSURL string `json:"acs_url,omitempty"`
RelayState string `json:"relay_state,omitempty"`
RequestID string `json:"request_id,omitempty"`
Binding string `json:"binding,omitempty"`
Issuer string `json:"issuer,omitempty"`
Destination string `json:"destination,omitempty"`
}
func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = event
}
func (e *AddedEvent) Payload() interface{} {
return e
}
func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func NewAddedEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
loginClient,
applicationID string,
acsURL string,
relayState string,
requestID string,
binding string,
issuer string,
destination string,
) *AddedEvent {
return &AddedEvent{
BaseEvent: eventstore.NewBaseEventForPush(
ctx,
aggregate,
AddedType,
),
LoginClient: loginClient,
ApplicationID: applicationID,
ACSURL: acsURL,
RelayState: relayState,
RequestID: requestID,
Binding: binding,
Issuer: issuer,
Destination: destination,
}
}
type SessionLinkedEvent struct {
*eventstore.BaseEvent `json:"-"`
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
AuthTime time.Time `json:"auth_time"`
AuthMethods []domain.UserAuthMethodType `json:"auth_methods"`
}
func (e *SessionLinkedEvent) Payload() interface{} {
return e
}
func (e *SessionLinkedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func NewSessionLinkedEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
sessionID,
userID string,
authTime time.Time,
authMethods []domain.UserAuthMethodType,
) *SessionLinkedEvent {
return &SessionLinkedEvent{
BaseEvent: eventstore.NewBaseEventForPush(
ctx,
aggregate,
SessionLinkedType,
),
SessionID: sessionID,
UserID: userID,
AuthTime: authTime,
AuthMethods: authMethods,
}
}
func (e *SessionLinkedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = event
}
type FailedEvent struct {
*eventstore.BaseEvent `json:"-"`
Reason domain.SAMLErrorReason `json:"reason,omitempty"`
}
func (e *FailedEvent) Payload() interface{} {
return e
}
func (e *FailedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func NewFailedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
reason domain.SAMLErrorReason,
) *FailedEvent {
return &FailedEvent{
BaseEvent: eventstore.NewBaseEventForPush(
ctx,
aggregate,
FailedType,
),
Reason: reason,
}
}
func (e *FailedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = event
}
type SucceededEvent struct {
*eventstore.BaseEvent `json:"-"`
}
func (e *SucceededEvent) Payload() interface{} {
return nil
}
func (e *SucceededEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func NewSucceededEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
) *SucceededEvent {
return &SucceededEvent{
BaseEvent: eventstore.NewBaseEventForPush(
ctx,
aggregate,
SucceededType,
),
}
}
func (e *SucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = event
}

View File

@ -0,0 +1,25 @@
package samlsession
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
AggregateType = "saml_session"
AggregateVersion = "v1"
)
type Aggregate struct {
eventstore.Aggregate
}
func NewAggregate(id, resourceOwner string) *Aggregate {
return &Aggregate{
Aggregate: eventstore.Aggregate{
Type: AggregateType,
Version: AggregateVersion,
ID: id,
ResourceOwner: resourceOwner,
},
}
}

View File

@ -0,0 +1,12 @@
package samlsession
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
func init() {
eventstore.RegisterFilterEventMapper(AggregateType, AddedType, eventstore.GenericEventMapper[AddedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, SAMLResponseAddedType, eventstore.GenericEventMapper[SAMLResponseAddedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, SAMLResponseRevokedType, eventstore.GenericEventMapper[SAMLResponseRevokedEvent])
}

View File

@ -0,0 +1,139 @@
package samlsession
import (
"context"
"time"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
samlSessionEventPrefix = "saml_session."
AddedType = samlSessionEventPrefix + "added"
SAMLResponseAddedType = samlSessionEventPrefix + "saml_response.added"
SAMLResponseRevokedType = samlSessionEventPrefix + "saml_response.revoked"
)
type AddedEvent struct {
eventstore.BaseEvent `json:"-"`
UserID string `json:"userID"`
UserResourceOwner string `json:"userResourceOwner"`
SessionID string `json:"sessionID"`
EntityID string `json:"entityID"`
Audience []string `json:"audience"`
AuthMethods []domain.UserAuthMethodType `json:"authMethods"`
AuthTime time.Time `json:"authTime"`
PreferredLanguage *language.Tag `json:"preferredLanguage,omitempty"`
UserAgent *domain.UserAgent `json:"userAgent,omitempty"`
}
func (e *AddedEvent) Payload() interface{} {
return e
}
func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewAddedEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
userID,
userResourceOwner,
sessionID,
entityID string,
audience []string,
authMethods []domain.UserAuthMethodType,
authTime time.Time,
preferredLanguage *language.Tag,
userAgent *domain.UserAgent,
) *AddedEvent {
return &AddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
AddedType,
),
UserID: userID,
UserResourceOwner: userResourceOwner,
SessionID: sessionID,
EntityID: entityID,
Audience: audience,
AuthMethods: authMethods,
AuthTime: authTime,
PreferredLanguage: preferredLanguage,
UserAgent: userAgent,
}
}
type SAMLResponseAddedEvent struct {
eventstore.BaseEvent `json:"-"`
ID string `json:"id,omitempty"`
Lifetime time.Duration `json:"lifetime,omitempty"`
}
func (e *SAMLResponseAddedEvent) Payload() interface{} {
return e
}
func (e *SAMLResponseAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *SAMLResponseAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewSAMLResponseAddedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
id string,
lifetime time.Duration,
) *SAMLResponseAddedEvent {
return &SAMLResponseAddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
SAMLResponseAddedType,
),
ID: id,
Lifetime: lifetime,
}
}
type SAMLResponseRevokedEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *SAMLResponseRevokedEvent) Payload() interface{} {
return e
}
func (e *SAMLResponseRevokedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *SAMLResponseRevokedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewSAMLResponseRevokedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
) *SAMLResponseRevokedEvent {
return &SAMLResponseRevokedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
SAMLResponseRevokedType,
),
}
}

View File

@ -566,6 +566,13 @@ Errors:
Token:
Invalid: Токенът е невалиден
Expired: Токенът е изтекъл
InvalidClient: Токенът не е издаден за този клиент
SAMLRequest:
AlreadyExists: SAMLRequest вече съществува
NotExisting: SAMLRequest не съществува
WrongLoginClient: SAMLRequest, създаден от друг клиент за влизане
SAMLSession:
InvalidClient: SAMLResponse не е издаден за този клиент
Feature:
NotExisting: Функцията не съществува
TypeNotSupported: Типът функция не се поддържа
@ -640,6 +647,8 @@ AggregateTypes:
system: Система
session: Сесия
web_key: Уеб ключ
saml_request: SAML заявка
saml_session: SAML сесия
EventTypes:
execution:

View File

@ -547,6 +547,12 @@ Errors:
Invalid: Token je neplatný
Expired: Token vypršel
InvalidClient: Token nebyl vydán pro tohoto klienta
SAMLRequest:
AlreadyExists: SAMLRequest již existuje
NotExisting: SAMLRequest neexistuje
WrongLoginClient: SAMLRequest vytvořený jiným přihlašovacím klientem
SAMLSession:
InvalidClient: Pro tohoto klienta nebyla vydána odpověď SAMLResponse
Feature:
NotExisting: Funkce neexistuje
TypeNotSupported: Typ funkce není podporován
@ -621,6 +627,8 @@ AggregateTypes:
system: Systém
session: Sezení
web_key: Webový klíč
saml_request: Žádost SAML
saml_session: Relace SAML
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: Token ist ungültig
Expired: Token ist abgelaufen
InvalidClient: Token wurde nicht für diesen Client ausgestellt
SAMLRequest:
AlreadyExists: SAMLRequest existiert bereits
NotExisting: SAMLRequest existiert nicht
WrongLoginClient: SAMLRequest wurde con einem andere Login-Client erstellt
SAMLSession:
InvalidClient: SAMLResponse wurde nicht für diesen Client ausgestellt
Feature:
NotExisting: Feature existiert nicht
TypeNotSupported: Feature Typ wird nicht unterstützt
@ -623,6 +629,8 @@ AggregateTypes:
system: System
session: Session
web_key: Webschlüssel
saml_request: SAML Request
saml_session: SAML Session
EventTypes:
execution:

View File

@ -550,6 +550,12 @@ Errors:
Invalid: Token is invalid
Expired: Token is expired
InvalidClient: Token was not issued for this client
SAMLRequest:
AlreadyExists: SAMLRequest already exists
NotExisting: SAMLRequest does not exist
WrongLoginClient: SAMLRequest created by other login client
SAMLSession:
InvalidClient: SAMLResponse was not issued for this client
Feature:
NotExisting: Feature does not exist
TypeNotSupported: Feature type is not supported
@ -624,6 +630,8 @@ AggregateTypes:
system: System
session: Session
web_key: Web Key
saml_request: SAML Request
saml_session: SAML Session
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: El token no es válido
Expired: El token ha caducado
InvalidClient: El token no ha sido emitido para este cliente
SAMLRequest:
AlreadyExists: SAMLRequest ya existe
NotExisting: SAMLRequest no existe
WrongLoginClient: SAMLRequest creado por otro cliente de inicio de sesión
SAMLSession:
InvalidClient: SAMLResponse no ha sido emitido para este cliente
Feature:
NotExisting: La característica no existe
TypeNotSupported: El tipo de característica no es compatible
@ -623,6 +629,8 @@ AggregateTypes:
system: Sistema
session: Sesión
web_key: Clave web
saml_request: Solicitud SAML
saml_session: Sesión SAML
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: Le jeton n'est pas valide
Expired: Le jeton est expiré
InvalidClient: Le token n'a pas été émis pour ce client
SAMLRequest:
AlreadyExists: SAMLRequest existe déjà
NotExisting: SAMLRequest n'existe pas
WrongLoginClient: SAMLRequest créé par un autre client de connexion
SAMLSession:
InvalidClient: SAMLResponse n'a pas été émise pour ce client
Feature:
NotExisting: La fonctionnalité n'existe pas
TypeNotSupported: Le type de fonctionnalité n'est pas pris en charge
@ -623,6 +629,8 @@ AggregateTypes:
system: Système
session: Session
web_key: Clé Web
saml_request: Requête SAML
saml_session: Session SAML
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: A Token érvénytelen
Expired: A Token lejárt
InvalidClient: A Token nem ehhez a klienshez lett kiadva
SAMLRequest:
AlreadyExists: A SAMLRequest már létezik
NotExisting: A SAMLRequest nem létezik
WrongLoginClient: A SAMLRequest egy másik bejelentkezési ügyfél által létrehozott
SAMLSession:
InvalidClient: SAMLResponse nem lett kiadva ehhez az ügyfélhez
Feature:
NotExisting: A funkció nem létezik
TypeNotSupported: A funkció típusa nem támogatott
@ -599,6 +605,7 @@ Errors:
FeatureDisabled: A webkulcs funkció le van tiltva
NoActive: Aktív web kulcs nem található
NotFound: Web kulcs nem található
AggregateTypes:
action: Művelet
instance: Példány
@ -622,6 +629,9 @@ AggregateTypes:
system: Rendszer
session: Munkamenet
web_key: Webkulcs
saml_request: SAML-kérés
saml_session: SAML munkamenet
EventTypes:
execution:
set: Végrehajtási készlet

View File

@ -549,6 +549,12 @@ Errors:
Invalid: Token tidak valid
Expired: Token sudah habis masa berlakunya
InvalidClient: Token tidak dikeluarkan untuk klien ini
SAMLRequest:
AlreadyExists: SAMLRequest sudah ada
NotExisting: SAMLRequest tidak ada
WrongLoginClient: SAMLRequest dibuat oleh klien login lainnya
SAMLSession:
InvalidClient: SAMLResponse tidak dikeluarkan untuk klien ini
Feature:
NotExisting: Fitur tidak ada
TypeNotSupported: Jenis fitur tidak didukung
@ -594,6 +600,7 @@ Errors:
FeatureDisabled: Fitur kunci web dinonaktifkan
NoActive: Tidak ditemukan kunci web aktif
NotFound: Kunci web tidak ditemukan
AggregateTypes:
action: Tindakan
instance: Contoh
@ -617,6 +624,9 @@ AggregateTypes:
system: Sistem
session: Sidang
web_key: Kunci Web
saml_request: Sesi SAML
saml_session: Permintaan SAML
EventTypes:
execution:
set: Kumpulan eksekusi

View File

@ -549,6 +549,12 @@ Errors:
Invalid: Token non è valido
Expired: Token è scaduto
InvalidClient: Il token non è stato emesso per questo cliente
SAMLRequest:
AlreadyExists: SAMLRequest esiste già
NotExisting: SAMLRequest non esiste
WrongLoginClient: SAMLRequest creato da un altro client di accesso
SAMLSession:
InvalidClient: SAMLResponse non è stato emesso per questo client
Feature:
NotExisting: La funzionalità non esiste
TypeNotSupported: Il tipo di funzionalità non è supportato
@ -623,6 +629,8 @@ AggregateTypes:
system: Sistema
session: Sessione
web_key: Chiave Web
saml_request: Richiesta SAML
saml_session: Sessione SAML
EventTypes:
execution:

View File

@ -538,6 +538,12 @@ Errors:
Invalid: トークンが無効です
Expired: トークンの有効期限が切れている
InvalidClient: トークンが発行されていません
SAMLRequest:
AlreadyExists: SAMLリクエストはすでに存在します
NotExisting: SAMLリクエストが存在しません
WrongLoginClient: 他のログイン クライアントによって作成された SAMLRequest
SAMLSession:
InvalidClient: このクライアントに対してSAMLResponseは発行されませんでした
Feature:
NotExisting: 機能が存在しません
TypeNotSupported: 機能タイプはサポートされていません
@ -612,6 +618,8 @@ AggregateTypes:
system: システム
session: セッション
web_key: Web キー
saml_request: SAML リクエスト
saml_session: SAMLセッション
EventTypes:
execution:

View File

@ -550,6 +550,12 @@ Errors:
Invalid: 토큰이 유효하지 않습니다
Expired: 토큰이 만료되었습니다
InvalidClient: 토큰이 이 클라이언트에 대해 발행되지 않았습니다
SAMLRequest:
AlreadyExists: SAMLRequest가 이미 존재합니다
NotExisting: SAMLRequest가 존재하지 않습니다
WrongLoginClient: 다른 로그인 클라이언트가 생성한 SAMLRequest
SAMLSession:
InvalidClient: 이 클라이언트에 대해 SAMLResponse가 발행되지 않았습니다.
Feature:
NotExisting: 기능이 존재하지 않습니다
TypeNotSupported: 기능 유형이 지원되지 않습니다
@ -624,6 +630,8 @@ AggregateTypes:
system: 시스템
session: 세션
web_key: 웹 키
saml_request: SAML 요청
saml_session: SAML 세션
EventTypes:
execution:

View File

@ -548,6 +548,12 @@ Errors:
Invalid: токенот е неважечки
Expired: токенот е истечен
InvalidClient: Токен не беше издаден на овој клиент
SAMLRequest:
AlreadyExists: SAMLRequest веќе постои
NotExisting: SAMLRequest не постои
WrongLoginClient: SAML Барање создадено од друг клиент за најавување
SAMLSession:
InvalidClient: SAMLResponse не беше издаден за овој клиент
Feature:
NotExisting: Функцијата не постои
TypeNotSupported: Типот на функција не е поддржан
@ -622,6 +628,8 @@ AggregateTypes:
system: Систем
session: Сесија
web_key: Веб клуч
saml_request: Барање SAML
saml_session: SAML сесија
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: Token is ongeldig
Expired: Token is verlopen
InvalidClient: Token is niet uitgegeven voor deze client
SAMLRequest:
AlreadyExists: SAMLRequest bestaat al
NotExisting: SAMLRequest bestaat niet
WrongLoginClient: SAMLRequest aangemaakt door andere login client
SAMLSession:
InvalidClient: SAMLResponse is niet uitgegeven voor deze client
Feature:
NotExisting: Functie bestaat niet
TypeNotSupported: Functie type wordt niet ondersteund
@ -623,6 +629,8 @@ AggregateTypes:
system: Systeem
session: Sessie
web_key: Websleutel
saml_request: SAML-aanvraag
saml_session: SAML-sessie
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: Token jest nieprawidłowy
Expired: Token wygasł
InvalidClient: Token nie został wydany dla tego klienta
SAMLRequest:
AlreadyExists: SAMLRequest już istnieje
NotExisting: SAMLRequest nie istnieje
WrongLoginClient: SAMLRequest utworzony przez innego klienta logowania
SAMLSession:
InvalidClient: SAMLResponse nie został wydany dla tego klienta
Feature:
NotExisting: Funkcja nie istnieje
TypeNotSupported: Typ funkcji nie jest obsługiwany
@ -623,6 +629,8 @@ AggregateTypes:
system: System
session: Sesja
web_key: Klucz internetowy
saml_request: Żądanie SAML
saml_session: Sesja SAML
EventTypes:
execution:

View File

@ -544,6 +544,16 @@ Errors:
WrongLoginClient: A solicitação de autenticação foi criada por outro cliente de login
OIDCSession:
RefreshTokenInvalid: O Refresh Token é inválido
Token:
Invalid: O token é inválido
Expired: O token expirou
InvalidClient: O token não foi emitido para este cliente
SAMLRequest:
AlreadyExists: O SAMLRequest já existe
NotExisting: O SAMLRequest não existe
WrongLoginClient: SAMLRequest criado por outro cliente de login
SAMLSession:
InvalidClient: O SAMLResponse não foi emitido para este cliente
Feature:
NotExisting: O recurso não existe
TypeNotSupported: O tipo de recurso não é compatível
@ -618,6 +628,8 @@ AggregateTypes:
system: Sistema
session: Sessão
web_key: Chave da Web
saml_request: Solicitação SAML
saml_session: Sessão SAML
EventTypes:
execution:

View File

@ -538,6 +538,12 @@ Errors:
Invalid: Токен недействителен
Expired: Срок действия токена истек
InvalidClient: Токен не был выпущен для этого клиента
SAMLRequest:
AlreadyExists: SAMLRequest уже существует
NotExisting: SAMLRequest не существует
WrongLoginClient: SAMLRequest создан другим клиентом входа
SAMLSession:
InvalidClient: SAMLResponse не был отправлен для этого клиента
Feature:
NotExisting: ункция не существует
TypeNotSupported: Тип объекта не поддерживается
@ -612,6 +618,8 @@ AggregateTypes:
system: Система
session: Сеанс
web_key: Веб-ключ
saml_request: SAML-запрос
saml_session: Сессия SAML
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: Token är ogiltig
Expired: Token har gått ut
InvalidClient: Token utfärdades inte för denna klient
SAMLRequest:
AlreadyExists: SAMLRequest finns redan
NotExisting: SAMLRequest finns inte
WrongLoginClient: SAMLRequest skapad av annan inloggningsklient
SAMLSession:
InvalidClient: SAMLResponse utfärdades inte för den här klienten
Feature:
NotExisting: Funktionen existerar inte
TypeNotSupported: Funktionstypen stöds inte
@ -623,6 +629,8 @@ AggregateTypes:
system: System
session: Session
web_key: Webbnyckel
saml_request: SAML-förfrågan
saml_session: SAML-session
EventTypes:
execution:

View File

@ -549,6 +549,12 @@ Errors:
Invalid: 令牌无效
Expired: 令牌已过期
InvalidClient: 没有为该客户发放令牌
SAMLRequest:
AlreadyExists: SAMLRequest 已存在
NotExisting: SAMLRequest不存在
WrongLoginClient: 其他登录客户端创建的 SAMLRequest
SAMLSession:
InvalidClient: 未向该客户端发出 SAMLResponse
Feature:
NotExisting: 功能不存在
TypeNotSupported: 不支持功能类型
@ -623,6 +629,8 @@ AggregateTypes:
system: 系统
session: 会话
web_key: Web 密钥
saml_request: SAML 请求
saml_session: SAML 会话
EventTypes:
execution:

3
pkg/grpc/saml/v2/saml.go Normal file
View File

@ -0,0 +1,3 @@
package saml
type Redirect = isCreateResponseRequest_ResponseKind

View File

@ -169,8 +169,8 @@ message CreateCallbackRequest {
string auth_request_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Set this field when the authorization flow failed. It creates a callback URL to the application, with the error details set.";
ref: "https://openid.net/specs/openid-connect-core-1_0.html#AuthError";
description: "ID of the Auth Request.";
example: "\"163840776835432705\"";
}
];

View File

@ -0,0 +1,71 @@
syntax = "proto3";
package zitadel.saml.v2;
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/saml/v2;saml";
message SAMLRequest{
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
external_docs: {
url: "https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html";
description: "Find out more about SAMLRequest parameters";
}
};
string id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "ID of the SAMLRequest";
}
];
google.protobuf.Timestamp creation_date = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Time when the SAMLRequest was created";
}
];
string issuer = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "SAML entityID of the application that created the SAMLRequest";
}
];
string assertion_consumer_service = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "URL which points back to the assertion consumer service of the application";
}
];
string relay_state = 5 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "RelayState provided by the application for the request";
}
];
string binding = 6 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Binding used by the application for the request";
}
];
}
message AuthorizationError {
ErrorReason error = 1;
optional string error_description = 2;
}
enum ErrorReason {
ERROR_REASON_UNSPECIFIED = 0;
ERROR_REASON_VERSION_MISSMATCH = 1;
ERROR_REASON_AUTH_N_FAILED = 2;
ERROR_REASON_INVALID_ATTR_NAME_OR_VALUE = 3;
ERROR_REASON_INVALID_NAMEID_POLICY = 4;
ERROR_REASON_REQUEST_DENIED =5;
ERROR_REASON_REQUEST_UNSUPPORTED = 6;
ERROR_REASON_UNSUPPORTED_BINDING = 7;
}

View File

@ -0,0 +1,227 @@
syntax = "proto3";
package zitadel.saml.v2;
import "zitadel/object/v2/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/saml/v2/authorization.proto";
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/saml/v2;saml";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {
title: "SAML Service";
version: "2.0";
description: "Get SAML Auth Request details and create callback URLs.";
contact:{
name: "ZITADEL"
url: "https://zitadel.com"
email: "hi@zitadel.com"
}
license: {
name: "Apache 2.0",
url: "https://github.com/zitadel/zitadel/blob/main/LICENSE";
};
};
schemes: HTTPS;
schemes: HTTP;
consumes: "application/json";
consumes: "application/grpc";
produces: "application/json";
produces: "application/grpc";
consumes: "application/grpc-web+proto";
produces: "application/grpc-web+proto";
host: "$CUSTOM-DOMAIN";
base_path: "/";
external_docs: {
description: "Detailed information about ZITADEL",
url: "https://zitadel.com/docs"
}
security_definitions: {
security: {
key: "OAuth2";
value: {
type: TYPE_OAUTH2;
flow: FLOW_ACCESS_CODE;
authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize";
token_url: "$CUSTOM-DOMAIN/oauth/v2/token";
scopes: {
scope: {
key: "openid";
value: "openid";
}
scope: {
key: "urn:zitadel:iam:org:project:id:zitadel:aud";
value: "urn:zitadel:iam:org:project:id:zitadel:aud";
}
}
}
}
}
security: {
security_requirement: {
key: "OAuth2";
value: {
scope: "openid";
scope: "urn:zitadel:iam:org:project:id:zitadel:aud";
}
}
}
responses: {
key: "403";
value: {
description: "Returned when the user does not have permission to access the resource.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
responses: {
key: "404";
value: {
description: "Returned when the resource does not exist.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
};
service SAMLService {
rpc GetSAMLRequest (GetSAMLRequestRequest) returns (GetSAMLRequestResponse) {
option (google.api.http) = {
get: "/v2/saml/saml_requests/{saml_request_id}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Get SAML Request details";
description: "Get SAML Request details by ID. Returns details that are parsed from the application's SAML Request."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
rpc CreateResponse (CreateResponseRequest) returns (CreateResponseResponse) {
option (google.api.http) = {
post: "/v2/saml/saml_requests/{saml_request_id}"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Finalize a SAML Request and get the response.";
description: "Finalize a SAML Request and get the response definition for success or failure. The response must be handled as per the SAML definition to inform the application about the success or failure. On success, the response contains details for the application to obtain the SAMLResponse. This method can only be called once for an SAML request."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
}
message GetSAMLRequestRequest {
// ID of the SAML Request, as obtained from the redirect URL.
string saml_request_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"163840776835432705\"";
}
];
}
message GetSAMLRequestResponse {
SAMLRequest saml_request = 1;
}
message CreateResponseRequest {
// ID of the SAML Request.
string saml_request_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"163840776835432705\"";
}
];
oneof response_kind {
option (validate.required) = true;
Session session = 2;
// Set this field when the authorization flow failed. It creates a response depending on the SP, with the error details set.
AuthorizationError error = 3;
}
}
message Session {
// ID of the session, used to login the user. Connects the session to the SAML Request.
string session_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"163840776835432705\"";
}
];
// Token to verify the session is valid.
string session_token = 2 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
}
];
}
message CreateResponseResponse {
zitadel.object.v2.Details details = 1;
// URL including the Assertion Consumer Service where the user should be redirected or has to call per POST, depending on the binding. Contains details for the application to obtain the response on success, or error details on failure. Note that this field must be treated as credentials, as the contained SAMLResponse or code can be used on behalve of the user.
string url = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"https://client.example.org/cb\""
}
];
// Binding is defined through the request, what the IDP is able to use and what bindings are available for the SP.
oneof binding {
// Set if the binding is Redirect-Binding, where the user can directly be redirected to the application, using a \"302 FOUND\" status to the URL.
RedirectResponse redirect = 3;
// Set if the binding is POST-Binding, where the application expects to be called per HTTP POST with the SAMLResponse and RelayState in the form body.
PostResponse post = 4;
}
}
message RedirectResponse{}
message PostResponse{
string relay_state = 1;
string saml_response = 2;
}