feat: saml application configuration for login version (#9351)

# Which Problems Are Solved

OIDC applications can configure the used login version, which is
currently not possible for SAML applications.

# How the Problems Are Solved

Add the same functionality dependent on the feature-flag for SAML
applications.

# Additional Changes

None

# Additional Context

Closes #9267
Follow up issue for frontend changes #9354

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz
2025-02-13 17:03:05 +01:00
committed by GitHub
parent 66296db971
commit 49de5c61b2
40 changed files with 1051 additions and 240 deletions

View File

@@ -66,9 +66,11 @@ type OIDCApp struct {
}
type SAMLApp struct {
Metadata []byte
MetadataURL string
EntityID string
Metadata []byte
MetadataURL string
EntityID string
LoginVersion domain.LoginVersion
LoginBaseURI *string
}
type APIApp struct {
@@ -137,6 +139,10 @@ var (
name: projection.AppSAMLTable,
instanceIDCol: projection.AppSAMLConfigColumnInstanceID,
}
AppSAMLConfigColumnInstanceID = Column{
name: projection.AppSAMLConfigColumnInstanceID,
table: appSAMLConfigsTable,
}
AppSAMLConfigColumnAppID = Column{
name: projection.AppSAMLConfigColumnAppID,
table: appSAMLConfigsTable,
@@ -153,6 +159,14 @@ var (
name: projection.AppSAMLConfigColumnMetadataURL,
table: appSAMLConfigsTable,
}
AppSAMLConfigColumnLoginVersion = Column{
name: projection.AppSAMLConfigColumnLoginVersion,
table: appSAMLConfigsTable,
}
AppSAMLConfigColumnLoginBaseURI = Column{
name: projection.AppSAMLConfigColumnLoginBaseURI,
table: appSAMLConfigsTable,
}
)
var (
@@ -320,30 +334,6 @@ func (q *Queries) AppByID(ctx context.Context, appID string, activeOnly bool) (a
return app, err
}
func (q *Queries) ActiveAppBySAMLEntityID(ctx context.Context, entityID string) (app *App, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
stmt, scan := prepareSAMLAppQuery(ctx, q.client)
eq := sq.Eq{
AppSAMLConfigColumnEntityID.identifier(): entityID,
AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
AppColumnState.identifier(): domain.AppStateActive,
ProjectColumnState.identifier(): domain.ProjectStateActive,
OrgColumnState.identifier(): domain.OrgStateActive,
}
query, args, err := stmt.Where(eq).ToSql()
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-JgUop", "Errors.Query.SQLStatement")
}
err = q.client.QueryRowContext(ctx, func(row *sql.Row) error {
app, err = scan(row)
return err
}, query, args...)
return app, err
}
func (q *Queries) ProjectByClientID(ctx context.Context, appID string) (project *Project, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
@@ -591,7 +581,7 @@ func (q *Queries) OIDCClientLoginVersion(ctx context.Context, clientID string) (
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
query, scan := prepareLoginVersionByClientID(ctx, q.client)
query, scan := prepareLoginVersionByOIDCClientID(ctx, q.client)
eq := sq.Eq{
AppOIDCConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
AppOIDCConfigColumnClientID.identifier(): clientID,
@@ -611,6 +601,30 @@ func (q *Queries) OIDCClientLoginVersion(ctx context.Context, clientID string) (
return loginVersion, nil
}
func (q *Queries) SAMLAppLoginVersion(ctx context.Context, appID string) (loginVersion domain.LoginVersion, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
query, scan := prepareLoginVersionBySAMLAppID(ctx, q.client)
eq := sq.Eq{
AppSAMLConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
AppSAMLConfigColumnAppID.identifier(): appID,
}
stmt, args, err := query.Where(eq).ToSql()
if err != nil {
return domain.LoginVersionUnspecified, zerrors.ThrowInvalidArgument(err, "QUERY-TnaciwZfp3", "Errors.Query.InvalidRequest")
}
err = q.client.QueryRowContext(ctx, func(row *sql.Row) error {
loginVersion, err = scan(row)
return err
}, stmt, args...)
if err != nil {
return domain.LoginVersionUnspecified, zerrors.ThrowInternal(err, "QUERY-lvDDwRzIoP", "Errors.Internal")
}
return loginVersion, nil
}
func NewAppNameSearchQuery(method TextComparison, value string) (SearchQuery, error) {
return NewTextQuery(AppColumnName, value, method)
}
@@ -659,6 +673,8 @@ func prepareAppQuery(ctx context.Context, db prepareDatabase, activeOnly bool) (
AppSAMLConfigColumnEntityID.identifier(),
AppSAMLConfigColumnMetadata.identifier(),
AppSAMLConfigColumnMetadataURL.identifier(),
AppSAMLConfigColumnLoginVersion.identifier(),
AppSAMLConfigColumnLoginBaseURI.identifier(),
).From(appsTable.identifier()).
PlaceholderFormat(sq.Dollar)
@@ -726,6 +742,8 @@ func scanApp(row *sql.Row) (*App, error) {
&samlConfig.entityID,
&samlConfig.metadata,
&samlConfig.metadataURL,
&samlConfig.loginVersion,
&samlConfig.loginBaseURI,
)
if err != nil {
@@ -827,61 +845,6 @@ func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) {
}
}
func prepareSAMLAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) {
return sq.Select(
AppColumnID.identifier(),
AppColumnName.identifier(),
AppColumnProjectID.identifier(),
AppColumnCreationDate.identifier(),
AppColumnChangeDate.identifier(),
AppColumnResourceOwner.identifier(),
AppColumnState.identifier(),
AppColumnSequence.identifier(),
AppSAMLConfigColumnAppID.identifier(),
AppSAMLConfigColumnEntityID.identifier(),
AppSAMLConfigColumnMetadata.identifier(),
AppSAMLConfigColumnMetadataURL.identifier(),
).From(appsTable.identifier()).
Join(join(AppSAMLConfigColumnAppID, AppColumnID)).
Join(join(ProjectColumnID, AppColumnProjectID)).
Join(join(OrgColumnID, AppColumnResourceOwner)).
PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) {
app := new(App)
var (
samlConfig = sqlSAMLConfig{}
)
err := row.Scan(
&app.ID,
&app.Name,
&app.ProjectID,
&app.CreationDate,
&app.ChangeDate,
&app.ResourceOwner,
&app.State,
&app.Sequence,
&samlConfig.appID,
&samlConfig.entityID,
&samlConfig.metadata,
&samlConfig.metadataURL,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowNotFound(err, "QUERY-d6TO1", "Errors.App.NotExisting")
}
return nil, zerrors.ThrowInternal(err, "QUERY-NAtPg", "Errors.Internal")
}
samlConfig.set(app)
return app, nil
}
}
func prepareProjectIDByAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (projectID string, err error)) {
return sq.Select(
AppColumnProjectID.identifier(),
@@ -1031,6 +994,8 @@ func prepareAppsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder
AppSAMLConfigColumnEntityID.identifier(),
AppSAMLConfigColumnMetadata.identifier(),
AppSAMLConfigColumnMetadataURL.identifier(),
AppSAMLConfigColumnLoginVersion.identifier(),
AppSAMLConfigColumnLoginBaseURI.identifier(),
countColumn.identifier(),
).From(appsTable.identifier()).
LeftJoin(join(AppAPIConfigColumnAppID, AppColumnID)).
@@ -1086,6 +1051,8 @@ func prepareAppsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder
&samlConfig.entityID,
&samlConfig.metadata,
&samlConfig.metadataURL,
&samlConfig.loginVersion,
&samlConfig.loginBaseURI,
&apps.Count,
)
@@ -1135,7 +1102,7 @@ func prepareClientIDsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu
}
}
func prepareLoginVersionByClientID(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (domain.LoginVersion, error)) {
func prepareLoginVersionByOIDCClientID(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (domain.LoginVersion, error)) {
return sq.Select(
AppOIDCConfigColumnLoginVersion.identifier(),
).From(appOIDCConfigsTable.identifier()).
@@ -1150,6 +1117,21 @@ func prepareLoginVersionByClientID(ctx context.Context, db prepareDatabase) (sq.
}
}
func prepareLoginVersionBySAMLAppID(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (domain.LoginVersion, error)) {
return sq.Select(
AppSAMLConfigColumnLoginVersion.identifier(),
).From(appSAMLConfigsTable.identifier()).
PlaceholderFormat(sq.Dollar), func(row *sql.Row) (domain.LoginVersion, error) {
var loginVersion sql.NullInt16
if err := row.Scan(
&loginVersion,
); err != nil {
return domain.LoginVersionUnspecified, zerrors.ThrowInternal(err, "QUERY-KbzaCnaziI", "Errors.Internal")
}
return domain.LoginVersion(loginVersion.Int16), nil
}
}
type sqlOIDCConfig struct {
appID sql.NullString
version sql.NullInt32
@@ -1209,10 +1191,12 @@ func (c sqlOIDCConfig) set(app *App) {
}
type sqlSAMLConfig struct {
appID sql.NullString
entityID sql.NullString
metadataURL sql.NullString
metadata []byte
appID sql.NullString
entityID sql.NullString
metadataURL sql.NullString
metadata []byte
loginVersion sql.NullInt16
loginBaseURI sql.NullString
}
func (c sqlSAMLConfig) set(app *App) {
@@ -1220,9 +1204,13 @@ func (c sqlSAMLConfig) set(app *App) {
return
}
app.SAMLConfig = &SAMLApp{
MetadataURL: c.metadataURL.String,
Metadata: c.metadata,
EntityID: c.entityID.String,
EntityID: c.entityID.String,
MetadataURL: c.metadataURL.String,
Metadata: c.metadata,
LoginVersion: domain.LoginVersion(c.loginVersion.Int16),
}
if c.loginBaseURI.Valid {
app.SAMLConfig.LoginBaseURI = &c.loginBaseURI.String
}
}

View File

@@ -56,7 +56,9 @@ var (
` projections.apps7_saml_configs.app_id,` +
` projections.apps7_saml_configs.entity_id,` +
` projections.apps7_saml_configs.metadata,` +
` projections.apps7_saml_configs.metadata_url` +
` projections.apps7_saml_configs.metadata_url,` +
` projections.apps7_saml_configs.login_version,` +
` projections.apps7_saml_configs.login_base_uri` +
` FROM projections.apps7` +
` LEFT JOIN projections.apps7_api_configs ON projections.apps7.id = projections.apps7_api_configs.app_id AND projections.apps7.instance_id = projections.apps7_api_configs.instance_id` +
` LEFT JOIN projections.apps7_oidc_configs ON projections.apps7.id = projections.apps7_oidc_configs.app_id AND projections.apps7.instance_id = projections.apps7_oidc_configs.instance_id` +
@@ -103,6 +105,8 @@ var (
` projections.apps7_saml_configs.entity_id,` +
` projections.apps7_saml_configs.metadata,` +
` projections.apps7_saml_configs.metadata_url,` +
` projections.apps7_saml_configs.login_version,` +
` projections.apps7_saml_configs.login_base_uri,` +
` COUNT(*) OVER ()` +
` FROM projections.apps7` +
` LEFT JOIN projections.apps7_api_configs ON projections.apps7.id = projections.apps7_api_configs.app_id AND projections.apps7.instance_id = projections.apps7_api_configs.instance_id` +
@@ -178,6 +182,8 @@ var (
"entity_id",
"metadata",
"metadata_url",
"login_version",
"login_base_uri",
}
appsCols = append(appCols, "count")
)
@@ -252,6 +258,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -321,6 +329,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -393,6 +403,8 @@ func Test_AppsPrepare(t *testing.T) {
"https://test.com/saml/metadata",
[]byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
"https://test.com/saml/metadata",
domain.LoginVersionUnspecified,
nil,
},
},
),
@@ -467,6 +479,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -559,6 +573,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -651,6 +667,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -743,6 +761,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -835,6 +855,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -927,6 +949,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -1019,6 +1043,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
{
"api-app-id",
@@ -1059,6 +1085,8 @@ func Test_AppsPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
{
"saml-app-id",
@@ -1099,6 +1127,8 @@ func Test_AppsPrepare(t *testing.T) {
"https://test.com/saml/metadata",
[]byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
"https://test.com/saml/metadata",
domain.LoginVersion2,
"https://login.ch/",
},
},
),
@@ -1165,9 +1195,11 @@ func Test_AppsPrepare(t *testing.T) {
Name: "app-name",
ProjectID: "project-id",
SAMLConfig: &SAMLApp{
Metadata: []byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
MetadataURL: "https://test.com/saml/metadata",
EntityID: "https://test.com/saml/metadata",
Metadata: []byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
MetadataURL: "https://test.com/saml/metadata",
EntityID: "https://test.com/saml/metadata",
LoginVersion: domain.LoginVersion2,
LoginBaseURI: gu.Ptr("https://login.ch/"),
},
},
},
@@ -1280,6 +1312,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
),
},
@@ -1343,6 +1377,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -1411,6 +1447,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -1498,6 +1536,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -1585,6 +1625,8 @@ func Test_AppPrepare(t *testing.T) {
"https://test.com/saml/metadata",
[]byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
"https://test.com/saml/metadata",
domain.LoginVersionUnspecified,
nil,
},
},
),
@@ -1599,9 +1641,11 @@ func Test_AppPrepare(t *testing.T) {
Name: "app-name",
ProjectID: "project-id",
SAMLConfig: &SAMLApp{
Metadata: []byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
MetadataURL: "https://test.com/saml/metadata",
EntityID: "https://test.com/saml/metadata",
Metadata: []byte("<?xml version=\"1.0\"?>\n<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\n validUntil=\"2022-08-26T14:08:16Z\"\n cacheDuration=\"PT604800S\"\n entityID=\"https://test.com/saml/metadata\">\n <md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\n <md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\"\n Location=\"https://test.com/saml/acs\"\n index=\"1\" />\n \n </md:SPSSODescriptor>\n</md:EntityDescriptor>"),
MetadataURL: "https://test.com/saml/metadata",
EntityID: "https://test.com/saml/metadata",
LoginVersion: domain.LoginVersionUnspecified,
LoginBaseURI: nil,
},
},
},
@@ -1654,6 +1698,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -1741,6 +1787,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -1828,6 +1876,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),
@@ -1915,6 +1965,8 @@ func Test_AppPrepare(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
},
},
),

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"database/sql/driver"
_ "embed"
"net/url"
"regexp"
"testing"
@@ -19,6 +20,8 @@ import (
var (
//go:embed testdata/oidc_client_jwt.json
testdataOidcClientJWT string
//go:embed testdata/oidc_client_jwt_loginversion.json
testdataOidcClientJWTLoginVersion string
//go:embed testdata/oidc_client_public.json
testdataOidcClientPublic string
//go:embed testdata/oidc_client_public_old_id.json
@@ -91,6 +94,44 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx
},
},
},
{
name: "jwt client, login version",
mock: mockQuery(expQuery, cols, []driver.Value{testdataOidcClientJWTLoginVersion}, "instanceID", "clientID", true),
want: &OIDCClient{
InstanceID: "230690539048009730",
AppID: "236647088211886082",
State: domain.AppStateActive,
ClientID: "236647088211951618",
HashedSecret: "",
RedirectURIs: []string{"http://localhost:9999/auth/callback"},
ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode},
GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode, domain.OIDCGrantTypeRefreshToken},
ApplicationType: domain.OIDCApplicationTypeWeb,
AuthMethodType: domain.OIDCAuthMethodTypePrivateKeyJWT,
PostLogoutRedirectURIs: []string{"https://example.com/logout"},
IsDevMode: true,
AccessTokenType: domain.OIDCTokenTypeJWT,
AccessTokenRoleAssertion: true,
IDTokenRoleAssertion: true,
IDTokenUserinfoAssertion: true,
ClockSkew: 1000000000,
AdditionalOrigins: []string{"https://example.com"},
ProjectID: "236645808328409090",
ProjectRoleAssertion: true,
PublicKeys: map[string][]byte{"236647201860747266": []byte(pubkey)},
ProjectRoleKeys: []string{"role1", "role2"},
Settings: &OIDCSettings{
AccessTokenLifetime: 43200000000000,
IdTokenLifetime: 43200000000000,
},
LoginVersion: domain.LoginVersion1,
LoginBaseURI: func() *URL {
ret, _ := url.Parse("https://test.com/login")
retURL := URL(*ret)
return &retURL
}(),
},
},
{
name: "public client",
mock: mockQuery(expQuery, cols, []driver.Value{testdataOidcClientPublic}, "instanceID", "clientID", true),

View File

@@ -62,12 +62,14 @@ const (
AppOIDCConfigColumnLoginVersion = "login_version"
AppOIDCConfigColumnLoginBaseURI = "login_base_uri"
appSAMLTableSuffix = "saml_configs"
AppSAMLConfigColumnAppID = "app_id"
AppSAMLConfigColumnInstanceID = "instance_id"
AppSAMLConfigColumnEntityID = "entity_id"
AppSAMLConfigColumnMetadata = "metadata"
AppSAMLConfigColumnMetadataURL = "metadata_url"
appSAMLTableSuffix = "saml_configs"
AppSAMLConfigColumnAppID = "app_id"
AppSAMLConfigColumnInstanceID = "instance_id"
AppSAMLConfigColumnEntityID = "entity_id"
AppSAMLConfigColumnMetadata = "metadata"
AppSAMLConfigColumnMetadataURL = "metadata_url"
AppSAMLConfigColumnLoginVersion = "login_version"
AppSAMLConfigColumnLoginBaseURI = "login_base_uri"
)
type appProjection struct{}
@@ -143,6 +145,8 @@ func (*appProjection) Init() *old_handler.Check {
handler.NewColumn(AppSAMLConfigColumnEntityID, handler.ColumnTypeText),
handler.NewColumn(AppSAMLConfigColumnMetadata, handler.ColumnTypeBytes),
handler.NewColumn(AppSAMLConfigColumnMetadataURL, handler.ColumnTypeText),
handler.NewColumn(AppSAMLConfigColumnLoginVersion, handler.ColumnTypeEnum, handler.Nullable()),
handler.NewColumn(AppSAMLConfigColumnLoginBaseURI, handler.ColumnTypeText, handler.Nullable()),
},
handler.NewPrimaryKey(AppSAMLConfigColumnInstanceID, AppSAMLConfigColumnAppID),
appSAMLTableSuffix,
@@ -703,6 +707,8 @@ func (p *appProjection) reduceSAMLConfigAdded(event eventstore.Event) (*handler.
handler.NewCol(AppSAMLConfigColumnEntityID, e.EntityID),
handler.NewCol(AppSAMLConfigColumnMetadata, e.Metadata),
handler.NewCol(AppSAMLConfigColumnMetadataURL, e.MetadataURL),
handler.NewCol(AppSAMLConfigColumnLoginVersion, e.LoginVersion),
handler.NewCol(AppSAMLConfigColumnLoginBaseURI, e.LoginBaseURI),
},
handler.WithTableSuffix(appSAMLTableSuffix),
),
@@ -735,6 +741,12 @@ func (p *appProjection) reduceSAMLConfigChanged(event eventstore.Event) (*handle
if e.EntityID != "" {
cols = append(cols, handler.NewCol(AppSAMLConfigColumnEntityID, e.EntityID))
}
if e.LoginVersion != nil {
cols = append(cols, handler.NewCol(AppSAMLConfigColumnLoginVersion, *e.LoginVersion))
}
if e.LoginBaseURI != nil {
cols = append(cols, handler.NewCol(AppSAMLConfigColumnLoginBaseURI, *e.LoginBaseURI))
}
if len(cols) == 0 {
return handler.NewNoOpStatement(e), nil

104
internal/query/saml_sp.go Normal file
View File

@@ -0,0 +1,104 @@
package query
import (
"context"
"database/sql"
_ "embed"
"errors"
"net/url"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type SAMLServiceProvider struct {
InstanceID string `json:"instance_id,omitempty"`
AppID string `json:"app_id,omitempty"`
State domain.AppState `json:"state,omitempty"`
EntityID string `json:"entity_id,omitempty"`
Metadata []byte `json:"metadata,omitempty"`
MetadataURL string `json:"metadata_url,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ProjectRoleAssertion bool `json:"project_role_assertion,omitempty"`
LoginVersion domain.LoginVersion `json:"login_version,omitempty"`
LoginBaseURI *url.URL `json:"login_base_uri,omitempty"`
}
//go:embed saml_sp_by_id.sql
var samlSPQuery string
func (q *Queries) ActiveSAMLServiceProviderByID(ctx context.Context, entityID string) (sp *SAMLServiceProvider, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
err = q.client.QueryRowContext(ctx, func(row *sql.Row) error {
sp, err = scanSAMLServiceProviderByID(row)
return err
}, samlSPQuery,
authz.GetInstance(ctx).InstanceID(),
entityID,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowNotFound(err, "QUERY-HeOcis2511", "Errors.App.NotFound")
}
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-OyJx1Rp30z", "Errors.Internal")
}
instance := authz.GetInstance(ctx)
loginV2 := instance.Features().LoginV2
if loginV2.Required {
sp.LoginVersion = domain.LoginVersion2
sp.LoginBaseURI = loginV2.BaseURI
}
return sp, err
}
func scanSAMLServiceProviderByID(row *sql.Row) (*SAMLServiceProvider, error) {
var instanceID, appID, entityID, metadataURL, projectID sql.NullString
var projectRoleAssertion sql.NullBool
var metadata []byte
var state, loginVersion sql.NullInt16
var loginBaseURI sql.NullString
err := row.Scan(
&instanceID,
&appID,
&state,
&entityID,
&metadata,
&metadataURL,
&projectID,
&projectRoleAssertion,
&loginVersion,
&loginBaseURI,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowNotFound(err, "QUERY-8cjj8ao6yY", "Errors.App.NotFound")
}
return nil, zerrors.ThrowInternal(err, "QUERY-1xzFD209Bp", "Errors.Internal")
}
sp := &SAMLServiceProvider{
InstanceID: instanceID.String,
AppID: appID.String,
State: domain.AppState(state.Int16),
EntityID: entityID.String,
Metadata: metadata,
MetadataURL: metadataURL.String,
ProjectID: projectID.String,
ProjectRoleAssertion: projectRoleAssertion.Bool,
}
if loginVersion.Valid {
sp.LoginVersion = domain.LoginVersion(loginVersion.Int16)
}
if loginBaseURI.Valid && loginBaseURI.String != "" {
url, err := url.Parse(loginBaseURI.String)
if err != nil {
return nil, err
}
sp.LoginBaseURI = url
}
return sp, nil
}

View File

@@ -0,0 +1,19 @@
select c.instance_id,
c.app_id,
a.state,
c.entity_id,
c.metadata,
c.metadata_url,
a.project_id,
p.project_role_assertion,
c.login_version,
c.login_base_uri
from projections.apps7_saml_configs c
join projections.apps7 a
on a.id = c.app_id and a.instance_id = c.instance_id and a.state = 1
join projections.projects4 p
on p.id = a.project_id and p.instance_id = a.instance_id and p.state = 1
join projections.orgs1 o
on o.id = p.resource_owner and o.instance_id = c.instance_id and o.org_state = 1
where c.instance_id = $1
and c.entity_id = $2

View File

@@ -0,0 +1,123 @@
package query
import (
"database/sql"
"database/sql/driver"
_ "embed"
"net/url"
"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/domain"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestQueries_ActiveSAMLServiceProviderByID(t *testing.T) {
expQuery := regexp.QuoteMeta(samlSPQuery)
cols := []string{
"instance_id",
"app_id",
"state",
"entity_id",
"metadata",
"metadata_url",
"project_id",
"project_role_assertion",
"login_version",
"login_base_uri",
}
tests := []struct {
name string
mock sqlExpectation
want *SAMLServiceProvider
wantErr error
}{
{
name: "no rows",
mock: mockQueryErr(expQuery, sql.ErrNoRows, "instanceID", "entityID"),
wantErr: zerrors.ThrowNotFound(sql.ErrNoRows, "QUERY-HeOcis2511", "Errors.App.NotFound"),
},
{
name: "internal error",
mock: mockQueryErr(expQuery, sql.ErrConnDone, "instanceID", "entityID"),
wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-OyJx1Rp30z", "Errors.Internal"),
},
{
name: "sp",
mock: mockQuery(expQuery, cols, []driver.Value{
"230690539048009730",
"236647088211886082",
domain.AppStateActive,
"https://test.com/metadata",
"metadata",
"https://test.com/metadata",
"236645808328409090",
true,
domain.LoginVersionUnspecified,
"",
}, "instanceID", "entityID"),
want: &SAMLServiceProvider{
InstanceID: "230690539048009730",
AppID: "236647088211886082",
State: domain.AppStateActive,
EntityID: "https://test.com/metadata",
Metadata: []byte("metadata"),
MetadataURL: "https://test.com/metadata",
ProjectID: "236645808328409090",
ProjectRoleAssertion: true,
},
},
{
name: "sp with loginversion",
mock: mockQuery(expQuery, cols, []driver.Value{
"230690539048009730",
"236647088211886082",
domain.AppStateActive,
"https://test.com/metadata",
"metadata",
"https://test.com/metadata",
"236645808328409090",
true,
domain.LoginVersion2,
"https://test.com/login",
}, "instanceID", "entityID"),
want: &SAMLServiceProvider{
InstanceID: "230690539048009730",
AppID: "236647088211886082",
State: domain.AppStateActive,
EntityID: "https://test.com/metadata",
Metadata: []byte("metadata"),
MetadataURL: "https://test.com/metadata",
ProjectID: "236645808328409090",
ProjectRoleAssertion: true,
LoginVersion: domain.LoginVersion2,
LoginBaseURI: func() *url.URL {
ret, _ := url.Parse("https://test.com/login")
return ret
}(),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
execMock(t, tt.mock, func(db *sql.DB) {
q := &Queries{
client: &database.DB{
DB: db,
Database: &prepareDB{},
},
}
ctx := authz.NewMockContext("instanceID", "orgID", "loginClient")
got, err := q.ActiveSAMLServiceProviderByID(ctx, "entityID")
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
})
}
}

View File

@@ -0,0 +1,32 @@
{
"instance_id": "230690539048009730",
"app_id": "236647088211886082",
"state": 1,
"client_id": "236647088211951618",
"client_secret": null,
"redirect_uris": ["http://localhost:9999/auth/callback"],
"response_types": [0],
"grant_types": [0, 2],
"application_type": 0,
"auth_method_type": 3,
"post_logout_redirect_uris": ["https://example.com/logout"],
"is_dev_mode": true,
"access_token_type": 1,
"access_token_role_assertion": true,
"id_token_role_assertion": true,
"id_token_userinfo_assertion": true,
"clock_skew": 1000000000,
"additional_origins": ["https://example.com"],
"project_id": "236645808328409090",
"project_role_assertion": true,
"project_role_keys": ["role1", "role2"],
"public_keys": {
"236647201860747266": "LS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFB\nT0NBUThBTUlJQkNnS0NBUUVBMnVmQUwxYjcyYkl5MWFyK1dzNmIKR29oSkpRRkI3ZGZSYXBEcWVx\nTThVa3A2Q1ZkUHpxL3BPejF2aUFxNTB5eldaSnJ5Risyd3NoRkFLR0Y5QTIvQgoyWWY5YkpYUFov\nS2JrRnJZVDNOVHZZRGt2bGFTVGw5bU1uenJVMjlzNDhGMVBUV0tmQitDM2FNc09FRzFCdWZWCnM2\nM3FGNG5yRVBqU2JobGpJY285RlpxNFhwcEl6aE1RMGZEZEEvK1h5Z0NKcXZ1YUwwTGliTTFLcmxV\nZG51NzEKWWVraFNKakVQbnZPaXNYSWs0SVh5d29HSU93dGp4a0R2Tkl0UXZhTVZsZHI0L2tiNnV2\nYmdkV3dxNUV3QlpYcQpsb3cya3lKb3YzOFY0VWsySThrdVhwTGNucnB3NVRpbzJvb2lVRTI3YjB2\nSFpxQktPZWk5VW84OHFDcm4zRUt4CjZRSURBUUFCCi0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0t\nLS0K"
},
"settings": {
"access_token_lifetime": 43200000000000,
"id_token_lifetime": 43200000000000
},
"login_version": 1,
"login_base_uri": "https://test.com/login"
}