feat(saml): implementation of saml for ZITADEL v2 (#3618)

This commit is contained in:
Stefan Benz 2022-09-12 17:18:08 +01:00 committed by GitHub
parent 01a92ba5d9
commit 7a5f7f82cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
134 changed files with 5570 additions and 1293 deletions

View File

@ -46,6 +46,10 @@ jobs:
working-directory: e2e
env:
ZITADEL_IMAGE: zitadel:pr
- name: Save ZITADEL Logs
if: always()
run: docker compose logs zitadel > ../.artifacts/e2e-compose-zitadel.log
working-directory: e2e
- name: Archive Test Results
if: always()
uses: actions/upload-artifact@v2
@ -55,4 +59,5 @@ jobs:
e2e/cypress/results
e2e/cypress/videos
e2e/cypress/screenshots
.artifacts/e2e-compose-zitadel.log
retention-days: 30

View File

@ -1,7 +1,7 @@
module.exports = {
branches: [
{name: 'main'},
{name: '1.87.x', range: '1.87.x', channel: '1.87.x'},
{name: '1.87.x', range: '1.87.x', channel: '1.87.x'}
],
plugins: [
"@semantic-release/commit-analyzer"

View File

@ -79,8 +79,8 @@ It's free for up to 25'000 authenticated requests. Learn more about the [pay-as-
- Self-registration including verification
- User self service
- [Service Accounts](https://docs.zitadel.com/docs/guides/integrate/serviceusers)
- [OpenID Connect certified](https://openid.net/certification/#OPs)
- 🚧 [SAML 2.0](https://github.com/zitadel/zitadel/pull/3618)
- [OpenID Connect certified](https://openid.net/certification/#OPs) => [OIDC Endpoints](https://docs.zitadel.com/docs/apis/openidoauth/endpoints), [OIDC Integration Guides](https://docs.zitadel.com/docs/guides/integrate/auth0-oidc)
- [SAML 2.0](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) => [SAML Endpoints](https://docs.zitadel.com/docs/apis/saml/endpoints), [SAML Integration Guides](https://docs.zitadel.com/docs/guides/integrate/auth0-saml)
- [Postgres](https://docs.zitadel.com/docs/guides/manage/self-hosted/database#postgres) (version >= 14) or [CockroachDB](https://docs.zitadel.com/docs/guides/manage/self-hosted/database#cockroach) (version >= 22.0)
Track upcoming features on our [roadmap](https://zitadel.com/roadmap).

View File

@ -200,6 +200,25 @@ OIDC:
Keys:
Path: /oauth/v2/keys
SAML:
ProviderConfig:
MetadataConfig:
Path: "/metadata"
SignatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
IDPConfig:
SignatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
WantAuthRequestsSigned: true
Endpoints:
#Organisation:
# Name: ZITADEL
# URL: https://zitadel.com
#ContactPerson:
# ContactType: "technical"
# Company: ZITADEL
# EmailAddress: hi@zitadel.com
Login:
LanguageCookieName: zitadel.login.lang
CSRFCookieName: zitadel.login.csrf
@ -234,6 +253,9 @@ EncryptionKeys:
OIDC:
EncryptionKeyID: "oidcKey"
DecryptionKeyIDs:
SAML:
EncryptionKeyID: "samlKey"
DecryptionKeyIDs:
OTP:
EncryptionKeyID: "otpKey"
DecryptionKeyIDs:
@ -277,8 +299,10 @@ SystemDefaults:
FileSystemPath: ".notifications/"
KeyConfig:
Size: 2048
CertificateSize: 4096
PrivateKeyLifetime: 6h
PublicKeyLifetime: 30h
CertificateLifetime: 8766h
DefaultInstance:
InstanceName:

View File

@ -70,7 +70,10 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
nil,
userAlg,
nil,
nil)
nil,
nil,
nil,
)
if err != nil {
return err

View File

@ -47,7 +47,10 @@ func (mig *externalConfigChange) Execute(ctx context.Context) error {
nil,
nil,
nil,
nil)
nil,
nil,
nil,
)
if err != nil {
return err

View File

@ -6,6 +6,7 @@ import (
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/saml"
admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
@ -45,6 +46,7 @@ type Config struct {
Admin admin_es.Config
UserAgentCookie *middleware.UserAgentCookieConfig
OIDC oidc.Config
SAML saml.Config
Login login.Config
Console console.Config
AssetStorage static_config.AssetStorageConfig
@ -90,6 +92,7 @@ type encryptionKeyConfig struct {
DomainVerification *crypto.KeyConfig
IDPConfig *crypto.KeyConfig
OIDC *crypto.KeyConfig
SAML *crypto.KeyConfig
OTP *crypto.KeyConfig
SMS *crypto.KeyConfig
SMTP *crypto.KeyConfig

View File

@ -10,6 +10,7 @@ var (
"domainVerificationKey",
"idpConfigKey",
"oidcKey",
"samlKey",
"otpKey",
"smsKey",
"smtpKey",
@ -23,6 +24,7 @@ type encryptionKeys struct {
DomainVerification crypto.EncryptionAlgorithm
IDPConfig crypto.EncryptionAlgorithm
OIDC crypto.EncryptionAlgorithm
SAML crypto.EncryptionAlgorithm
OTP crypto.EncryptionAlgorithm
SMS crypto.EncryptionAlgorithm
SMTP crypto.EncryptionAlgorithm
@ -49,6 +51,10 @@ func ensureEncryptionKeys(keyConfig *encryptionKeyConfig, keyStorage crypto.KeyS
if err != nil {
return nil, err
}
keys.SAML, err = crypto.NewAESCrypto(keyConfig.SAML, keyStorage)
if err != nil {
return nil, err
}
key, err := crypto.LoadKey(keyConfig.OIDC.EncryptionKeyID, keyStorage)
if err != nil {
return nil, err

View File

@ -13,6 +13,10 @@ import (
"syscall"
"time"
"github.com/zitadel/saml/pkg/provider"
"github.com/zitadel/zitadel/internal/api/saml"
"github.com/gorilla/mux"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -100,7 +104,7 @@ func startZitadel(config *Config, masterKey string) error {
return fmt.Errorf("cannot start eventstore for queries: %w", err)
}
queries, err := query.StartQueries(ctx, eventstoreClient, dbClient, config.Projections, config.SystemDefaults, keys.IDPConfig, keys.OTP, keys.OIDC, config.InternalAuthZ.RolePermissionMappings)
queries, err := query.StartQueries(ctx, eventstoreClient, dbClient, config.Projections, config.SystemDefaults, keys.IDPConfig, keys.OTP, keys.OIDC, keys.SAML, config.InternalAuthZ.RolePermissionMappings)
if err != nil {
return fmt.Errorf("cannot start queries: %w", err)
}
@ -134,6 +138,8 @@ func startZitadel(config *Config, masterKey string) error {
keys.User,
keys.DomainVerification,
keys.OIDC,
keys.SAML,
&http.Client{},
)
if err != nil {
return fmt.Errorf("cannot start commands: %w", err)
@ -208,13 +214,19 @@ func startAPIs(ctx context.Context, router *mux.Router, commands *command.Comman
return fmt.Errorf("unable to start oidc provider: %w", err)
}
samlProvider, err := saml.NewProvider(ctx, config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor)
if err != nil {
return fmt.Errorf("unable to start saml provider: %w", err)
}
apis.RegisterHandler(saml.HandlerPrefix, samlProvider.HttpHandler())
c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, instanceInterceptor.Handler, config.CustomerPortal)
if err != nil {
return fmt.Errorf("unable to start console: %w", err)
}
apis.RegisterHandler(console.HandlerPrefix, c)
l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, keys.User, keys.IDPConfig, keys.CSRFCookieKey)
l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, keys.User, keys.IDPConfig, keys.CSRFCookieKey)
if err != nil {
return fmt.Errorf("unable to start login: %w", err)
}

File diff suppressed because it is too large Load Diff

View File

@ -64,8 +64,8 @@
"@types/jasminewd2": "~2.0.10",
"@types/jsonwebtoken": "^8.5.5",
"@types/node": "^17.0.42",
"@typescript-eslint/eslint-plugin": "5.35.1",
"@typescript-eslint/parser": "5.30.4",
"@typescript-eslint/eslint-plugin": "5.36.1",
"@typescript-eslint/parser": "5.36.1",
"codelyzer": "^6.0.0",
"eslint": "^8.18.0",
"jasmine-core": "~4.2.0",

View File

@ -86,6 +86,7 @@ const authConfig: AuthConfig = {
scope: 'openid profile email', // offline_access
responseType: 'code',
oidc: true,
requireHttps: false,
};
@NgModule({

View File

@ -1,5 +1,13 @@
<div class="cnsl-app-card" [ngClass]="{'add': type === OIDCAppType.ADD,'web': type === OIDCAppType.OIDC_APP_TYPE_WEB,
'useragent': type === OIDCAppType.OIDC_APP_TYPE_USER_AGENT,
'native': type === OIDCAppType.OIDC_APP_TYPE_NATIVE, 'api': isApiApp}">
<div
class="cnsl-app-card"
[ngClass]="{
add: type === OIDCAppType.ADD,
web: type === OIDCAppType.OIDC_APP_TYPE_WEB,
useragent: type === OIDCAppType.OIDC_APP_TYPE_USER_AGENT,
native: type === OIDCAppType.OIDC_APP_TYPE_NATIVE,
api: isApiApp,
saml: type === 'SAML'
}"
>
<ng-content></ng-content>
</div>

View File

@ -55,5 +55,11 @@
border: none;
color: #fff;
}
&.saml {
background: linear-gradient(40deg, rgb(110, 56, 124), rgb(88, 37, 103));
border: none;
color: #fff;
}
}
}

View File

@ -8,7 +8,7 @@ import { OIDCAppType } from 'src/app/proto/generated/zitadel/app_pb';
})
export class AppCardComponent {
@Input() public outline: boolean = false;
@Input() public type: OIDCAppType | undefined = undefined;
@Input() public type: OIDCAppType | 'SAML' | undefined = undefined;
@Input() public isApiApp: boolean = false;
public OIDCAppType: any = OIDCAppType;
}

View File

@ -2,12 +2,13 @@
<ng-container *ngFor="let type of types">
<input class="app" type="radio" (change)="emitChange()" [value]="type" [(ngModel)]="selected" [id]="type.prefix" />
<label class="cnsl-type-radio-button" [for]="type.prefix">
<div class="cnsl-type-radio-header" [ngStyle]="{'background': type.background}">
<span>{{type.prefix}}</span>
<div class="cnsl-type-radio-header" [ngStyle]="{ background: type.background }">
<span>{{ type.prefix }}</span>
</div>
<p>{{type.titleI18nKey | translate}}</p>
<p class="type-desc cnsl-secondary-text">{{type.descI18nKey | translate}}</p>
<p>{{ type.titleI18nKey | translate }}</p>
<p class="type-desc cnsl-secondary-text">{{ type.descI18nKey | translate }}</p>
<span class="fill-space"></span>
<span class="cnsl-type-protocol state" *ngIf="type.protocol">{{ type.protocol }}</span>
</label>
</ng-container>
</div>

View File

@ -60,6 +60,8 @@
box-sizing: border-box;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
position: relative;
overflow: hidden;
span {
margin: 2rem;
@ -76,5 +78,10 @@
.type-desc {
font-size: 14px;
}
.cnsl-type-protocol {
width: fit-content;
margin: 0.5rem auto;
}
}
}

View File

@ -1,14 +1,24 @@
<cnsl-create-layout
title="{{ 'APP.PAGES.CREATE_OIDC' | translate }}"
[createSteps]="createSteps"
title="{{ 'APP.PAGES.CREATE' | translate }}"
[createSteps]="
appType?.value?.createType === AppCreateType.OIDC
? appType?.value.oidcAppType !== OIDCAppType.OIDC_APP_TYPE_NATIVE
? 4
: 3
: appType?.value?.createType === AppCreateType.API
? 3
: appType?.value?.createType === AppCreateType.SAML
? 3
: 0
"
[currentCreateStep]="currentCreateStep"
(closed)="close()"
>
<h1>{{ 'APP.PAGES.CREATE_OIDC_DESC_TITLE' | translate }}</h1>
<h1>{{ 'APP.PAGES.CREATE_DESC_TITLE' | translate }}</h1>
<mat-progress-bar class="progress-bar" color="primary" *ngIf="loading" mode="indeterminate"></mat-progress-bar>
<mat-checkbox class="proswitch" color="primary" [(ngModel)]="devmode">
{{ 'APP.OIDC.PROSWITCH' | translate }}
{{ 'APP.PROSWITCH' | translate }}
</mat-checkbox>
<mat-horizontal-stepper
@ -21,16 +31,16 @@
>
<mat-step [stepControl]="firstFormGroup" [editable]="true">
<form [formGroup]="firstFormGroup">
<ng-template matStepLabel>{{ 'APP.OIDC.NAMEANDTYPESECTION' | translate }}</ng-template>
<ng-template matStepLabel>{{ 'APP.NAMEANDTYPESECTION' | translate }}</ng-template>
<p class="step-title">{{ 'APP.OIDC.TITLEFIRST' | translate }}</p>
<p class="step-title">{{ 'APP.TITLEFIRST' | translate }}</p>
<cnsl-form-field class="name-formfield">
<cnsl-label>{{ 'APP.NAME' | translate }}</cnsl-label>
<input cnslInput cdkFocusInitial formControlName="name" />
<span cnslError *ngIf="name?.errors?.required">{{ 'PROJECT.APP.NAMEREQUIRED' | translate }}</span>
</cnsl-form-field>
<p class="step-title">{{ 'APP.OIDC.TYPETITLE' | translate }}</p>
<p class="step-title">{{ 'APP.TYPETITLE' | translate }}</p>
<cnsl-type-radio [types]="appTypes" (selectedType)="appType?.setValue($event)" [selected]="appType?.value">
</cnsl-type-radio>
@ -48,9 +58,13 @@
</form>
</mat-step>
<!-- skip for native applications -->
<!-- skip for native OIDC and SAML applications -->
<mat-step
*ngIf="oidcAppRequest.appType !== OIDCAppType.OIDC_APP_TYPE_NATIVE"
*ngIf="
(appType?.value?.createType === AppCreateType.OIDC &&
appType?.value.oidcAppType !== OIDCAppType.OIDC_APP_TYPE_NATIVE) ||
appType?.value?.createType === AppCreateType.API
"
[stepControl]="secondFormGroup"
[editable]="true"
>
@ -85,33 +99,39 @@
<ng-template matStepLabel>{{ 'APP.OIDC.REDIRECTSECTION' | translate }}</ng-template>
<p class="step-title">{{ 'APP.OIDC.REDIRECTTITLE' | translate }}</p>
<p class="step-description cnsl-secondary-text" *ngIf="oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE">
<p
class="step-description cnsl-secondary-text"
*ngIf="appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
>
{{ 'APP.OIDC.REDIRECTDESCRIPTIONNATIVE' | translate }}
</p>
<p class="step-description cnsl-secondary-text" *ngIf="oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_WEB">
<p class="step-description cnsl-secondary-text" *ngIf="appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_WEB">
{{ 'APP.OIDC.REDIRECTDESCRIPTIONWEB' | translate }}
</p>
<cnsl-redirect-uris
class="redirect-section"
[canWrite]="true"
[isNative]="oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
(changedUris)="oidcAppRequest.redirectUrisList = $any($event)"
[urisList]="oidcAppRequest.redirectUrisList"
[isNative]="appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
(changedUris)="oidcAppRequest.setRedirectUrisList($any($event))"
[urisList]="oidcAppRequest.toObject().redirectUrisList"
[getValues]="requestRedirectValuesSubject$"
title="{{ 'APP.OIDC.REDIRECT' | translate }}"
>
</cnsl-redirect-uris>
<p class="step-title">{{ 'APP.OIDC.POSTREDIRECTTITLE' | translate }}</p>
<p class="step-description cnsl-secondary-text" *ngIf="oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE">
<p
class="step-description cnsl-secondary-text"
*ngIf="appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
>
{{ 'APP.OIDC.REDIRECTDESCRIPTIONNATIVE' | translate }}
</p>
<p
class="step-description cnsl-secondary-text"
*ngIf="
oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_WEB ||
oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_USER_AGENT
appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_WEB ||
appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_USER_AGENT
"
>
{{ 'APP.OIDC.REDIRECTDESCRIPTIONWEB' | translate }}
@ -120,11 +140,11 @@
<cnsl-redirect-uris
class="redirect-section"
[canWrite]="true"
(changedUris)="oidcAppRequest.postLogoutRedirectUrisList = $any($event)"
[urisList]="oidcAppRequest.postLogoutRedirectUrisList"
(changedUris)="oidcAppRequest.setPostLogoutRedirectUrisList($any($event))"
[urisList]="oidcAppRequest.toObject().postLogoutRedirectUrisList"
title="{{ 'APP.OIDC.POSTLOGOUTREDIRECT' | translate }}"
[getValues]="requestRedirectValuesSubject$"
[isNative]="oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
[isNative]="appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
>
</cnsl-redirect-uris>
@ -136,97 +156,173 @@
</div>
</mat-step>
<mat-step *ngIf="appType?.value?.createType === AppCreateType.SAML" [editable]="true">
<ng-template matStepLabel>{{ 'APP.SAML.CONFIGSECTION' | translate }}</ng-template>
<form [formGroup]="samlConfigForm">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'APP.SAML.URL' | translate }}</cnsl-label>
<input cnslInput formControlName="metadataUrl" placeholder="https://" />
</cnsl-form-field>
</form>
<span class="cnsl-app-or cnsl-secondary-text">{{ 'APP.SAML.OR' | translate }}</span>
<input
#xmlFileInput
style="display: none"
class="file-input"
type="file"
accept="text/xml,application/xml"
(change)="onDropXML($any($event.target).files)"
/>
<button type="button" mat-stroked-button (click)="$event.preventDefault(); xmlFileInput.click()">
{{ 'APP.SAML.XML' | translate }}
</button>
<div
class="saml-xml"
[ngClass]="{ disabled: !!metadataUrl?.value }"
*ngIf="decodedBase64 && samlAppRequest.toObject().metadataXml && !samlAppRequest.toObject().metadataUrl"
>
<!-- <p class="preview-text cnsl-secondary-text">PREVIEW</p> -->
<ngx-codemirror
[(ngModel)]="decodedBase64"
[options]="{
lineNumbers: true,
theme: 'material',
mode: 'application/xml'
}"
></ngx-codemirror>
</div>
<div class="app-create-actions">
<button mat-stroked-button class="bck-button" matStepperPrevious>{{ 'ACTIONS.BACK' | translate }}</button>
<button mat-raised-button color="primary" matStepperNext [attr.data-e2e]="'continue-button-redirecturis'">
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</mat-step>
<mat-step>
<ng-template matStepLabel>{{ 'APP.OIDC.OVERVIEWSECTION' | translate }}</ng-template>
<p class="step-title">{{ 'APP.OIDC.OVERVIEWTITLE' | translate }}</p>
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.NAME' | translate }}
</span>
<span class="right">
{{ oidcAppRequest.name }}
{{ name?.value }}
</span>
</div>
<ng-container *ngIf="appType?.value?.createType === AppCreateType.OIDC">
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.TYPE' | translate }}
</span>
<span class="right">
{{ 'APP.OIDC.APPTYPE.' + oidcAppRequest.appType | translate }}
{{ 'APP.OIDC.APPTYPE.' + oidcAppRequest.toObject().appType | translate }}
</span>
</div>
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.GRANT' | translate }}
</span>
<span class="right" *ngIf="oidcAppRequest.grantTypesList && oidcAppRequest.grantTypesList.length > 0">
[<span *ngFor="let element of oidcAppRequest.grantTypesList; index as i">
<span
class="right"
*ngIf="oidcAppRequest.toObject().grantTypesList && oidcAppRequest.toObject().grantTypesList.length > 0"
>
[<span *ngFor="let element of oidcAppRequest.toObject().grantTypesList; index as i">
{{ 'APP.OIDC.GRANT.' + element | translate }}
{{ i < oidcAppRequest.grantTypesList.length - 1 ? ', ' : '' }} </span
{{ i < oidcAppRequest.toObject().grantTypesList.length - 1 ? ', ' : '' }} </span
>]
</span>
</div>
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.OIDC.RESPONSETYPE' | translate }}
</span>
<span class="right" *ngIf="oidcAppRequest.responseTypesList && oidcAppRequest.responseTypesList.length > 0">
[<span *ngFor="let element of oidcAppRequest.responseTypesList; index as i">
<span
class="right"
*ngIf="oidcAppRequest.toObject().responseTypesList && oidcAppRequest.toObject().responseTypesList.length > 0"
>
[<span *ngFor="let element of oidcAppRequest.toObject().responseTypesList; index as i">
{{ 'APP.OIDC.RESPONSE.' + element | translate }}
{{ i < oidcAppRequest.responseTypesList.length - 1 ? ', ' : '' }} </span
{{ i < oidcAppRequest.toObject().responseTypesList.length - 1 ? ', ' : '' }} </span
>]
</span>
</div>
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.AUTHMETHOD' | translate }}
</span>
<span class="right">
<span>
{{ 'APP.OIDC.AUTHMETHOD.' + oidcAppRequest.authMethodType | translate }}
{{ 'APP.OIDC.AUTHMETHOD.' + oidcAppRequest.toObject().authMethodType | translate }}
</span>
</span>
</div>
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.OIDC.REDIRECT' | translate }}
</span>
<span class="right" *ngIf="oidcAppRequest.redirectUrisList && oidcAppRequest.redirectUrisList.length > 0">
[<span *ngFor="let redirect of oidcAppRequest.redirectUrisList; index as i">
<span
class="right"
*ngIf="oidcAppRequest.toObject().redirectUrisList && oidcAppRequest.toObject().redirectUrisList.length > 0"
>
[<span *ngFor="let redirect of oidcAppRequest.toObject().redirectUrisList; index as i">
{{ redirect }}
{{ i < oidcAppRequest.redirectUrisList.length - 1 ? ', ' : '' }} </span
{{ i < oidcAppRequest.toObject().redirectUrisList.length - 1 ? ', ' : '' }} </span
>]
</span>
</div>
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.OIDC.POSTLOGOUTREDIRECT' | translate }}
</span>
<span
class="right"
*ngIf="oidcAppRequest.postLogoutRedirectUrisList && oidcAppRequest.postLogoutRedirectUrisList.length > 0"
*ngIf="
oidcAppRequest.toObject().postLogoutRedirectUrisList &&
oidcAppRequest.toObject().postLogoutRedirectUrisList.length > 0
"
>
[<span *ngFor="let redirect of oidcAppRequest.postLogoutRedirectUrisList; index as i">
[<span *ngFor="let redirect of oidcAppRequest.toObject().postLogoutRedirectUrisList; index as i">
{{ redirect }}
{{ i < oidcAppRequest.postLogoutRedirectUrisList.length - 1 ? ', ' : '' }} </span
{{ i < oidcAppRequest.toObject().postLogoutRedirectUrisList.length - 1 ? ', ' : '' }} </span
>]
</span>
</div>
</ng-container>
<ng-container *ngIf="appType?.value?.createType === AppCreateType.API">
<div class="row cnsl-secondary-text">
<span class="left">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.AUTHMETHOD' | translate }}
</span>
<span class="right">
<span>
{{ 'APP.API.AUTHMETHOD.' + apiAppRequest.authMethodType | translate }}
{{ 'APP.API.AUTHMETHOD.' + authMethodType?.value | translate }}
</span>
</span>
</div>
</ng-container>
<ng-container *ngIf="appType?.value?.createType === AppCreateType.SAML">
<div class="row">
<span class="left cnsl-secondary-text">
{{ 'APP.SAML.METADATA' | translate }}
</span>
<span class="right">
<span *ngIf="metadataUrl?.value">
{{ metadataUrl?.value }}
</span>
<span *ngIf="samlAppRequest.toObject().metadataXml">
{{ 'APP.SAML.METADATAFROMFILE' | translate }}
</span>
</span>
</div>
@ -278,6 +374,7 @@
</cnsl-form-field>
</ng-container>
<ng-container *ngIf="formappType?.value?.createType !== AppCreateType.SAML">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'APP.AUTHMETHOD' | translate }}</cnsl-label>
<mat-select formControlName="authMethodType">
@ -287,6 +384,46 @@
</mat-option>
</mat-select>
</cnsl-form-field>
</ng-container>
</div>
<div class="content">
<ng-container *ngIf="formappType?.value?.createType === AppCreateType.SAML">
<div class="saml">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'APP.SAML.URL' | translate }}</cnsl-label>
<input cnslInput formControlName="metadataUrl" placeholder="https://" />
</cnsl-form-field>
<span class="cnsl-app-or cnsl-secondary-text">{{ 'APP.SAML.OR' | translate }}</span>
<input
#xmlFileInput
style="display: none"
class="file-input"
type="file"
(change)="onDropXML($any($event.target).files)"
/>
<button type="button" mat-stroked-button (click)="$event.preventDefault(); xmlFileInput.click()">
{{ 'APP.SAML.XML' | translate }}
</button>
<div
class="saml-xml"
[ngClass]="{ disabled: !!formMetadataUrl?.value }"
*ngIf="decodedBase64 && samlAppRequest.toObject().metadataXml && !samlAppRequest.toObject().metadataUrl"
>
<ngx-codemirror
[(ngModel)]="decodedBase64"
[options]="{
lineNumbers: true,
theme: 'material',
mode: 'application/xml'
}"
></ngx-codemirror>
</div>
</div>
</ng-container>
</div>
<div class="content" *ngIf="formappType?.value?.createType === AppCreateType.OIDC">
@ -294,22 +431,22 @@
<cnsl-redirect-uris
class="redirect-section"
[canWrite]="true"
(changedUris)="oidcAppRequest.redirectUrisList = $any($event)"
[urisList]="oidcAppRequest.redirectUrisList"
(changedUris)="oidcAppRequest.setRedirectUrisList($any($event))"
[urisList]="oidcAppRequest.toObject().redirectUrisList"
title="{{ 'APP.OIDC.REDIRECT' | translate }}"
[getValues]="requestRedirectValuesSubject$"
[isNative]="oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
[isNative]="appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
>
</cnsl-redirect-uris>
<cnsl-redirect-uris
class="redirect-section"
[canWrite]="true"
(changedUris)="oidcAppRequest.postLogoutRedirectUrisList = $any($event)"
[urisList]="oidcAppRequest.postLogoutRedirectUrisList"
(changedUris)="oidcAppRequest.setPostLogoutRedirectUrisList($any($event))"
[urisList]="oidcAppRequest.toObject().postLogoutRedirectUrisList"
title="{{ 'APP.OIDC.POSTLOGOUTREDIRECT' | translate }}"
[getValues]="requestRedirectValuesSubject$"
[isNative]="oidcAppRequest.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
[isNative]="appType?.value.oidcAppType === OIDCAppType.OIDC_APP_TYPE_NATIVE"
>
</cnsl-redirect-uris>
</div>

View File

@ -61,6 +61,10 @@ p.desc {
}
}
.cnsl-app-or {
margin-right: 1rem;
}
.row {
display: flex;
justify-content: space-between;
@ -72,10 +76,20 @@ p.desc {
}
}
.saml-xml {
margin-top: 2rem;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
.app-create-actions {
margin-top: 1rem;
margin-top: 2rem;
display: flex;
align-items: center;
justify-content: space-between;
.bck-button {
margin-right: 1rem;
@ -102,6 +116,12 @@ p.desc {
flex-basis: 80%;
}
}
.saml {
display: block;
width: 100%;
margin: 0 0.5rem;
}
}
.continue-button {

View File

@ -5,6 +5,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { Buffer } from 'buffer';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { RadioItemAuthType } from 'src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component';
@ -14,12 +15,14 @@ import {
OIDCAuthMethodType,
OIDCGrantType,
OIDCResponseType,
SAMLConfig,
} from 'src/app/proto/generated/zitadel/app_pb';
import {
AddAPIAppRequest,
AddAPIAppResponse,
AddOIDCAppRequest,
AddOIDCAppResponse,
AddSAMLAppRequest,
} from 'src/app/proto/generated/zitadel/management_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
@ -35,7 +38,9 @@ import {
PKCE_METHOD,
POST_METHOD,
} from '../authmethods';
import { API_TYPE, AppCreateType, NATIVE_TYPE, RadioItemAppType, USER_AGENT_TYPE, WEB_TYPE } from '../authtypes';
import { API_TYPE, AppCreateType, NATIVE_TYPE, RadioItemAppType, SAML_TYPE, USER_AGENT_TYPE, WEB_TYPE } from '../authtypes';
const MAX_ALLOWED_SIZE = 1 * 1024 * 1024;
@Component({
selector: 'cnsl-app-create',
@ -49,11 +54,11 @@ export class AppCreateComponent implements OnInit, OnDestroy {
public projectId: string = '';
public loading: boolean = false;
public createSteps: number = 4;
public currentCreateStep: number = 1;
public oidcAppRequest: AddOIDCAppRequest.AsObject = new AddOIDCAppRequest().toObject();
public apiAppRequest: AddAPIAppRequest.AsObject = new AddAPIAppRequest().toObject();
public oidcAppRequest: AddOIDCAppRequest = new AddOIDCAppRequest();
public apiAppRequest: AddAPIAppRequest = new AddAPIAppRequest();
public samlAppRequest: AddSAMLAppRequest = new AddSAMLAppRequest();
public oidcResponseTypes: { type: OIDCResponseType; checked: boolean; disabled: boolean }[] = [
{ type: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE, checked: false, disabled: false },
@ -66,7 +71,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
OIDCAppType.OIDC_APP_TYPE_NATIVE,
OIDCAppType.OIDC_APP_TYPE_USER_AGENT,
];
public appTypes: any = [WEB_TYPE, NATIVE_TYPE, USER_AGENT_TYPE, API_TYPE];
public appTypes: any = [WEB_TYPE, NATIVE_TYPE, USER_AGENT_TYPE, API_TYPE, SAML_TYPE];
public authMethods: RadioItemAuthType[] = [PKCE_METHOD, CODE_METHOD, PK_JWT_METHOD, POST_METHOD];
@ -84,8 +89,12 @@ export class AppCreateComponent implements OnInit, OnDestroy {
];
// stepper
firstFormGroup!: UntypedFormGroup;
secondFormGroup!: UntypedFormGroup;
public firstFormGroup!: UntypedFormGroup;
public secondFormGroup!: UntypedFormGroup;
public samlConfigForm!: UntypedFormGroup;
public redirectUrisList: string[] = [];
public postLogoutRedirectUrisList: string[] = [];
// devmode
public form!: UntypedFormGroup;
@ -121,10 +130,13 @@ export class AppCreateComponent implements OnInit, OnDestroy {
) {
this.form = this.fb.group({
name: ['', [Validators.required]],
responseTypesList: ['', [Validators.required]],
grantTypesList: ['', [Validators.required]],
appType: ['', [Validators.required]],
authMethodType: ['', [Validators.required]],
// apptype OIDC
responseTypesList: ['', []],
grantTypesList: ['', []],
authMethodType: ['', []],
// apptype SAML
metadataUrl: ['', []],
});
this.initForm();
@ -134,25 +146,30 @@ export class AppCreateComponent implements OnInit, OnDestroy {
appType: [WEB_TYPE, [Validators.required]],
});
this.samlConfigForm = this.fb.group({
metadataUrl: ['', []],
});
this.firstFormGroup.valueChanges.subscribe((value) => {
if (this.firstFormGroup.valid) {
this.oidcAppRequest.name = this.name?.value;
this.apiAppRequest.name = this.name?.value;
this.oidcAppRequest.setName(this.name?.value);
this.apiAppRequest.setName(this.name?.value);
this.samlAppRequest.setName(this.name?.value);
if (this.isStepperOIDC) {
const oidcAppType = (this.appType?.value as RadioItemAppType).oidcAppType;
if (oidcAppType !== undefined) {
this.oidcAppRequest.appType = oidcAppType;
this.oidcAppRequest.setAppType(oidcAppType);
}
switch (this.oidcAppRequest.appType) {
switch (this.appType?.value.oidcAppType) {
case OIDCAppType.OIDC_APP_TYPE_NATIVE:
this.authMethods = [PKCE_METHOD];
// automatically set to PKCE and skip step
this.oidcAppRequest.responseTypesList = [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE];
this.oidcAppRequest.grantTypesList = [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE];
this.oidcAppRequest.authMethodType = OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE;
this.oidcAppRequest.setResponseTypesList([OIDCResponseType.OIDC_RESPONSE_TYPE_CODE]);
this.oidcAppRequest.setGrantTypesList([OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE]);
this.oidcAppRequest.setAuthMethodType(OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE);
break;
case OIDCAppType.OIDC_APP_TYPE_WEB:
@ -179,19 +196,28 @@ export class AppCreateComponent implements OnInit, OnDestroy {
this.secondFormGroup = this.fb.group({
authMethod: [this.authMethods[0].key, [Validators.required]],
});
this.secondFormGroup.valueChanges.subscribe((form) => {
const partialConfig = getPartialConfigFromAuthMethod(form.authMethod);
if (this.isStepperOIDC && partialConfig && partialConfig.oidc) {
this.oidcAppRequest.responseTypesList = partialConfig.oidc?.responseTypesList ?? [];
this.oidcAppRequest.setResponseTypesList(partialConfig.oidc?.responseTypesList ?? []);
this.oidcAppRequest.grantTypesList = partialConfig.oidc?.grantTypesList ?? [];
this.oidcAppRequest.setGrantTypesList(partialConfig.oidc?.grantTypesList ?? []);
this.oidcAppRequest.authMethodType =
partialConfig.oidc?.authMethodType ?? OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE;
this.oidcAppRequest.setAuthMethodType(
partialConfig.oidc?.authMethodType ?? OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
);
} else if (this.isStepperAPI && partialConfig && partialConfig.api) {
this.apiAppRequest.authMethodType =
partialConfig.api?.authMethodType ?? APIAuthMethodType.API_AUTH_METHOD_TYPE_BASIC;
this.apiAppRequest.setAuthMethodType(
partialConfig.api?.authMethodType ?? APIAuthMethodType.API_AUTH_METHOD_TYPE_BASIC,
);
}
});
this.samlConfigForm.valueChanges.subscribe((form) => {
if (form.metadataUrl && form.metadataUrl.length > 0) {
this.samlAppRequest.setMetadataUrl(form.metadataUrl);
}
});
}
@ -225,18 +251,23 @@ export class AppCreateComponent implements OnInit, OnDestroy {
public initForm(): void {
this.form.valueChanges.pipe(takeUntil(this.destroyed$), debounceTime(150)).subscribe(() => {
this.oidcAppRequest.name = this.formname?.value;
this.apiAppRequest.name = this.formname?.value;
this.oidcAppRequest.setName(this.formname?.value);
this.apiAppRequest.setName(this.formname?.value);
this.samlAppRequest.setName(this.formname?.value);
this.oidcAppRequest.responseTypesList = this.formresponseTypesList?.value;
this.oidcAppRequest.grantTypesList = this.formgrantTypesList?.value;
this.oidcAppRequest.setResponseTypesList(this.formresponseTypesList?.value);
this.oidcAppRequest.setGrantTypesList(this.grantTypesList?.value);
this.oidcAppRequest.authMethodType = this.formauthMethodType?.value;
this.apiAppRequest.authMethodType = this.formauthMethodType?.value;
this.oidcAppRequest.setAuthMethodType(this.authMethodType?.value);
this.apiAppRequest.setAuthMethodType(this.authMethodType?.value);
if (this.formMetadataUrl?.value) {
this.samlAppRequest.setMetadataUrl(this.formMetadataUrl?.value);
}
const oidcAppType = (this.formappType?.value as RadioItemAppType).oidcAppType;
if (oidcAppType !== undefined) {
this.oidcAppRequest.appType = oidcAppType;
this.oidcAppRequest.setAppType(oidcAppType);
}
});
@ -282,17 +313,41 @@ export class AppCreateComponent implements OnInit, OnDestroy {
private async getData({ projectid }: Params): Promise<void> {
this.projectId = projectid;
this.oidcAppRequest.projectId = projectid;
this.apiAppRequest.projectId = projectid;
this.oidcAppRequest.setProjectId(projectid);
this.apiAppRequest.setProjectId(projectid);
this.samlAppRequest.setProjectId(projectid);
}
public close(): void {
this._location.back();
}
public onDropXML(filelist: FileList): void {
const file = filelist.item(0);
this.metadataUrl?.setValue('');
if (file) {
if (file.size > MAX_ALLOWED_SIZE) {
this.toast.showInfo('POLICY.PRIVATELABELING.MAXSIZEEXCEEDED', true);
} else {
const reader = new FileReader();
reader.onload = ((aXML) => {
return (e) => {
const xmlBase64 = e.target?.result;
if (xmlBase64 && typeof xmlBase64 === 'string') {
const cropped = xmlBase64.replace('data:text/xml;base64,', '');
this.samlAppRequest.setMetadataXml(cropped);
}
};
})(file);
reader.readAsDataURL(file);
}
}
}
public createApp(): void {
const appOIDCCheck = this.devmode ? this.isDevOIDC : this.isStepperOIDC;
const appAPICheck = this.devmode ? this.isDevAPI : this.isStepperAPI;
const appSAMLCheck = this.devmode ? this.isDevSAML : this.isStepperSAML;
if (appOIDCCheck) {
this.requestRedirectValuesSubject$.next();
@ -331,6 +386,19 @@ export class AppCreateComponent implements OnInit, OnDestroy {
this.loading = false;
this.toast.showError(error);
});
} else if (appSAMLCheck) {
this.loading = true;
this.toast.showInfo('APP.TOAST.CREATED', true);
this.mgmtService
.addSAMLApp(this.samlAppRequest)
.then((resp) => {
this.loading = false;
this.router.navigate(['projects', this.projectId, 'apps', resp.appId]);
})
.catch((error) => {
this.loading = false;
this.toast.showError(error);
});
}
}
@ -381,19 +449,27 @@ export class AppCreateComponent implements OnInit, OnDestroy {
get formname(): AbstractControl | null {
return this.form.get('name');
}
get formresponseTypesList(): AbstractControl | null {
return this.form.get('responseTypesList');
}
get formgrantTypesList(): AbstractControl | null {
get grantTypesList(): AbstractControl | null {
return this.form.get('grantTypesList');
}
get formappType(): AbstractControl | null {
return this.form.get('appType');
}
get formMetadataUrl(): AbstractControl | null {
return this.form.get('metadataUrl');
}
// get formapplicationType(): AbstractControl | null {
// return this.form.get('applicationType');
// }
get formauthMethodType(): AbstractControl | null {
get authMethodType(): AbstractControl | null {
return this.form.get('authMethodType');
}
@ -409,7 +485,35 @@ export class AppCreateComponent implements OnInit, OnDestroy {
return (this.formappType?.value as RadioItemAppType).createType === AppCreateType.API;
}
get isDevSAML(): boolean {
return (this.formappType?.value as RadioItemAppType).createType === AppCreateType.SAML;
}
get isStepperAPI(): boolean {
return (this.appType?.value as RadioItemAppType).createType === AppCreateType.API;
}
get isStepperSAML(): boolean {
return (this.appType?.value as RadioItemAppType).createType === AppCreateType.SAML;
}
get decodedBase64(): string {
const samlReq = this.samlAppRequest.toObject();
if (samlReq && samlReq.metadataXml && typeof samlReq.metadataXml === 'string') {
return Buffer.from(samlReq.metadataXml, 'base64').toString('ascii');
} else {
return '';
}
}
set decodedBase64(xmlString) {
if (this.samlAppRequest) {
const base64 = Buffer.from(xmlString, 'ascii').toString('base64');
this.samlAppRequest.setMetadataXml(base64);
}
}
public get metadataUrl(): AbstractControl | null {
return this.samlConfigForm.get('metadataUrl');
}
}

View File

@ -2,7 +2,7 @@
title="{{ app?.name }}"
[hasActions]="isZitadel === false && (['project.app.write:' + projectId, 'project.app.write'] | hasRole | async)"
docLink="https://docs.zitadel.com/docs/guides/basics/projects"
[sub]="app?.oidcConfig ? ('APP.OIDC.APPTYPE.' + app?.oidcConfig?.appType | translate) : 'API'"
[sub]="app?.oidcConfig ? ('APP.OIDC.APPTYPE.' + app?.oidcConfig?.appType | translate) : app?.apiConfig ? 'API' : 'SAML'"
[isActive]="app?.state === AppState.APP_STATE_ACTIVE"
[isInactive]="app?.state === AppState.APP_STATE_INACTIVE"
stateTooltip="{{ 'APP.PAGES.DETAIL.STATE.' + app?.state | translate }}"
@ -156,6 +156,60 @@
</div>
</form>
</cnsl-card>
<cnsl-card *ngIf="samlForm && app?.samlConfig">
<form [formGroup]="samlForm">
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'APP.SAML.URL' | translate }}</cnsl-label>
<input cnslInput formControlName="metadataUrl" placeholder="https://" />
</cnsl-form-field>
</form>
<div class="cnsl-saml-config-line">
<span class="cnsl-app-or cnsl-secondary-text">{{ 'APP.SAML.OR' | translate }}</span>
<input
#xmlFileInput
style="display: none"
class="file-input"
type="file"
accept="text/xml,application/xml"
(change)="onDropXML($any($event.target).files)"
/>
<button mat-stroked-button (click)="$event.preventDefault(); xmlFileInput.click()">
{{ 'APP.SAML.XML' | translate }}
</button>
</div>
<div
class="saml-xml"
[ngClass]="{ disabled: !!metadataUrl?.value }"
*ngIf="decodedBase64 && app && app.samlConfig && !app.samlConfig.metadataUrl"
>
<ngx-codemirror
[(ngModel)]="decodedBase64"
[disabled]="!!metadataUrl?.value"
[options]="{
lineNumbers: true,
theme: 'material',
mode: 'application/xml'
}"
></ngx-codemirror>
</div>
<div class="btn-container">
<button
class="submit-button"
type="submit"
color="primary"
(click)="saveSAMLApp()"
[disabled]="samlForm.invalid || !canWrite"
mat-raised-button
>
{{ 'ACTIONS.SAVE' | translate }}
</button>
</div>
</cnsl-card>
</ng-container>
<ng-container *ngIf="currentSetting === 'token'">

View File

@ -158,6 +158,24 @@
margin-left: 1rem;
}
}
.cnsl-saml-config-line {
display: flex;
align-items: center;
.cnsl-app-or {
margin-right: 1rem;
}
}
.saml-xml {
margin-top: 2rem;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
}
}

View File

@ -7,6 +7,7 @@ import { MatCheckboxChange } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Buffer } from 'buffer';
import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
import { Subject, Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
@ -27,11 +28,13 @@ import {
OIDCGrantType,
OIDCResponseType,
OIDCTokenType,
SAMLConfig,
} from 'src/app/proto/generated/zitadel/app_pb';
import {
GetOIDCInformationResponse,
UpdateAPIAppConfigRequest,
UpdateOIDCAppConfigRequest,
UpdateSAMLAppConfigRequest,
} from 'src/app/proto/generated/zitadel/management_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
@ -52,6 +55,8 @@ import {
} from '../authmethods';
import { AuthMethodDialogComponent } from './auth-method-dialog/auth-method-dialog.component';
const MAX_ALLOWED_SIZE = 1 * 1024 * 1024;
@Component({
selector: 'cnsl-app-detail',
templateUrl: './app-detail.component.html',
@ -104,6 +109,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
public oidcForm!: UntypedFormGroup;
public oidcTokenForm!: UntypedFormGroup;
public apiForm!: UntypedFormGroup;
public samlForm!: UntypedFormGroup;
public redirectUrisList: string[] = [];
public postLogoutRedirectUrisList: string[] = [];
@ -162,6 +168,11 @@ export class AppDetailComponent implements OnInit, OnDestroy {
authMethodType: [{ value: '', disabled: true }],
});
this.samlForm = this.fb.group({
metadataUrl: [{ value: '', disabled: true }],
metadataXml: [{ value: '', disabled: true }],
});
this.http.get('./assets/environment.json').subscribe((env: any) => {
this.environmentMap = {
issuer: env.issuer,
@ -290,12 +301,15 @@ export class AppDetailComponent implements OnInit, OnDestroy {
} else {
this.authMethods = this.authMethods.filter((element) => element !== CUSTOM_METHOD);
}
} else if (this.app.samlConfig) {
this.settingsList = [{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' }];
}
if (allowed) {
this.oidcForm.enable();
this.oidcTokenForm.enable();
this.apiForm.enable();
this.samlForm.enable();
}
if (this.app.oidcConfig?.redirectUrisList) {
@ -370,6 +384,30 @@ export class AppDetailComponent implements OnInit, OnDestroy {
}
}
public onDropXML(filelist: FileList): void {
const file = filelist.item(0);
if (file) {
if (file.size > MAX_ALLOWED_SIZE) {
this.toast.showInfo('POLICY.PRIVATELABELING.MAXSIZEEXCEEDED', true);
} else {
this.metadataUrl?.setValue('');
const reader = new FileReader();
reader.onload = ((aXML) => {
return (e) => {
const xmlBase64 = e.target?.result;
if (xmlBase64 && typeof xmlBase64 === 'string' && this.app?.samlConfig) {
const samlConfig = new SAMLConfig();
const cropped = xmlBase64.replace('data:text/xml;base64,', '');
samlConfig.setMetadataXml(cropped);
this.app.samlConfig.metadataXml = cropped;
}
};
})(file);
reader.readAsDataURL(file);
}
}
}
public authMethodFromPartialConfig(config: { oidc?: OIDCConfig.AsObject; api?: APIConfig.AsObject }): string {
const key = getAuthMethodFromPartialConfig(config);
return key;
@ -581,6 +619,28 @@ export class AppDetailComponent implements OnInit, OnDestroy {
}
}
public saveSAMLApp(): void {
if (this.samlForm.valid && this.app?.samlConfig) {
const req = new UpdateSAMLAppConfigRequest();
req.setProjectId(this.projectId);
req.setAppId(this.app.id);
if (this.app.samlConfig) {
req.setMetadataUrl(this.app.samlConfig?.metadataUrl);
req.setMetadataXml(this.app.samlConfig?.metadataXml);
}
this.mgmtService
.updateSAMLAppConfig(req)
.then(() => {
this.toast.showInfo('APP.TOAST.APIUPDATED', true);
})
.catch((error) => {
this.toast.showError(error);
});
}
}
public regenerateOIDCClientSecret(): void {
if (this.app) {
this.mgmtService
@ -693,4 +753,31 @@ export class AppDetailComponent implements OnInit, OnDestroy {
public get clockSkewSeconds(): AbstractControl | null {
return this.oidcTokenForm.get('clockSkewSeconds');
}
public get metadataUrl(): AbstractControl | null {
return this.samlForm.get('metadataUrl');
}
get decodedBase64(): string {
if (
this.app &&
this.app.samlConfig &&
this.app.samlConfig.metadataXml &&
typeof this.app.samlConfig.metadataXml === 'string'
) {
return Buffer.from(this.app?.samlConfig.metadataXml, 'base64').toString('ascii');
} else {
return '';
}
}
set decodedBase64(xmlString: string) {
if (this.app && this.app.samlConfig && this.app.samlConfig.metadataXml) {
const base64 = Buffer.from(xmlString, 'ascii').toString('base64');
if (this.app.samlConfig) {
this.app.samlConfig.metadataXml = base64;
}
}
}
}

View File

@ -16,6 +16,7 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSliderModule } from '@angular/material/slider';
import { MatStepperModule } from '@angular/material/stepper';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CodemirrorModule } from '@ctrl/ngx-codemirror';
import { TranslateModule } from '@ngx-translate/core';
import { CopyToClipboardModule } from 'src/app/directives/copy-to-clipboard/copy-to-clipboard.module';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
@ -89,6 +90,7 @@ import { RedirectUrisComponent } from './redirect-uris/redirect-uris.component';
InputModule,
MetaLayoutModule,
MatSliderModule,
CodemirrorModule,
ChangesModule,
InfoSectionModule,
],

View File

@ -10,6 +10,7 @@ import { OIDCAppType } from 'src/app/proto/generated/zitadel/app_pb';
export enum AppCreateType {
API = 'API',
OIDC = 'OIDC',
SAML = 'SAML',
}
export interface RadioItemAppType {
@ -20,6 +21,7 @@ export interface RadioItemAppType {
descI18nKey: string;
prefix: string;
background: string;
protocol: 'OIDC' | 'SAML';
}
export const WEB_TYPE: RadioItemAppType = {
@ -30,6 +32,7 @@ export const WEB_TYPE: RadioItemAppType = {
oidcAppType: OIDCAppType.OIDC_APP_TYPE_WEB,
prefix: 'WEB',
background: 'linear-gradient(40deg, #059669 30%, #047857)',
protocol: 'OIDC',
};
export const USER_AGENT_TYPE: RadioItemAppType = {
@ -40,6 +43,7 @@ export const USER_AGENT_TYPE: RadioItemAppType = {
oidcAppType: OIDCAppType.OIDC_APP_TYPE_USER_AGENT,
prefix: 'UA',
background: 'linear-gradient(40deg, #dc2626 30%, #db2777)',
protocol: 'OIDC',
};
export const NATIVE_TYPE: RadioItemAppType = {
@ -50,6 +54,7 @@ export const NATIVE_TYPE: RadioItemAppType = {
oidcAppType: OIDCAppType.OIDC_APP_TYPE_NATIVE,
prefix: 'N',
background: 'linear-gradient(40deg, #306ccc 30%, #4f46e5)',
protocol: 'OIDC',
};
export const API_TYPE: RadioItemAppType = {
@ -59,4 +64,14 @@ export const API_TYPE: RadioItemAppType = {
createType: AppCreateType.API,
prefix: 'API',
background: 'linear-gradient(40deg, #1f2937, #111827)',
protocol: 'OIDC',
};
export const SAML_TYPE: RadioItemAppType = {
titleI18nKey: 'APP.SAML.SELECTION.TITLE',
descI18nKey: 'APP.SAML.SELECTION.DESCRIPTION',
createType: AppCreateType.SAML,
prefix: 'SAML',
background: 'linear-gradient(40deg,rgb(110, 56, 124), rgb(88, 37, 103))',
protocol: 'SAML',
};

View File

@ -17,20 +17,27 @@
*ngFor="let app of appsSubject | async"
matTooltip="{{ 'ACTIONS.EDIT' | translate }}"
>
<cnsl-app-card class="grid-card" matRipple [type]="app.oidcConfig?.appType" [isApiApp]="app.apiConfig !== undefined">
<cnsl-app-card
class="grid-card"
matRipple
[type]="app.samlConfig ? 'SAML' : app.oidcConfig?.appType"
[isApiApp]="app.apiConfig !== undefined"
>
{{ app.name.charAt(0) }}
<ng-container *ngIf="app.oidcConfig?.appType !== undefined">
<i *ngIf="app.oidcConfig?.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE" class="las la-mobile"></i>
<i *ngIf="app.oidcConfig?.appType === OIDCAppType.OIDC_APP_TYPE_WEB" class="las la-code"></i>
<i *ngIf="app.oidcConfig?.appType === OIDCAppType.OIDC_APP_TYPE_USER_AGENT" class="las la-code"></i>
<i *ngIf="app.oidcConfig?.appType === OIDCAppType.OIDC_APP_TYPE_NATIVE" class="lab la-openid"></i>
<i *ngIf="app.oidcConfig?.appType === OIDCAppType.OIDC_APP_TYPE_WEB" class="lab la-openid"></i>
<i *ngIf="app.oidcConfig?.appType === OIDCAppType.OIDC_APP_TYPE_USER_AGENT" class="lab la-openid"></i>
<i *ngIf="app.apiConfig" class="las la-robot"></i>
</ng-container>
<span *ngIf="app.samlConfig" class="samlspan">SAML</span>
</cnsl-app-card>
<span class="name">{{ app.name }}</span>
<span *ngIf="app.oidcConfig?.appType !== undefined && app.oidcConfig?.appType !== null" class="type">
{{ 'APP.OIDC.APPTYPE.' + app.oidcConfig?.appType | translate }}</span
>
<span *ngIf="app.apiConfig !== undefined" class="type"> API</span>
<span *ngIf="app.samlConfig !== undefined" class="type"> SAML</span>
</div>
<ng-template cnslHasRole [hasRole]="['project.app.write']">

View File

@ -49,6 +49,17 @@
border-radius: 0.5rem;
font-weight: 800;
box-sizing: border-box;
i {
font-size: 1.2rem;
}
.samlspan {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.05em;
line-height: 1.2rem;
}
}
.name {

View File

@ -63,6 +63,8 @@ import {
AddProjectResponse,
AddProjectRoleRequest,
AddProjectRoleResponse,
AddSAMLAppRequest,
AddSAMLAppResponse,
AddSecondFactorToLoginPolicyRequest,
AddSecondFactorToLoginPolicyResponse,
AddUserGrantRequest,
@ -423,6 +425,8 @@ import {
UpdateProjectResponse,
UpdateProjectRoleRequest,
UpdateProjectRoleResponse,
UpdateSAMLAppConfigRequest,
UpdateSAMLAppConfigResponse,
UpdateUserGrantRequest,
UpdateUserGrantResponse,
UpdateUserNameRequest,
@ -2268,27 +2272,18 @@ export class ManagementService {
return this.grpcService.mgmt.reactivateProjectGrant(req, null).then((resp) => resp.toObject());
}
public addOIDCApp(app: AddOIDCAppRequest.AsObject): Promise<AddOIDCAppResponse.AsObject> {
const req: AddOIDCAppRequest = new AddOIDCAppRequest();
req.setAuthMethodType(app.authMethodType);
req.setName(app.name);
req.setProjectId(app.projectId);
req.setResponseTypesList(app.responseTypesList);
req.setGrantTypesList(app.grantTypesList);
req.setAppType(app.appType);
req.setPostLogoutRedirectUrisList(app.postLogoutRedirectUrisList);
req.setRedirectUrisList(app.redirectUrisList);
public addOIDCApp(req: AddOIDCAppRequest): Promise<AddOIDCAppResponse.AsObject> {
return this.grpcService.mgmt.addOIDCApp(req, null).then((resp) => resp.toObject());
}
public addAPIApp(app: AddAPIAppRequest.AsObject): Promise<AddAPIAppResponse.AsObject> {
const req: AddAPIAppRequest = new AddAPIAppRequest();
req.setAuthMethodType(app.authMethodType);
req.setName(app.name);
req.setProjectId(app.projectId);
public addAPIApp(req: AddAPIAppRequest): Promise<AddAPIAppResponse.AsObject> {
return this.grpcService.mgmt.addAPIApp(req, null).then((resp) => resp.toObject());
}
public addSAMLApp(req: AddSAMLAppRequest): Promise<AddSAMLAppResponse.AsObject> {
return this.grpcService.mgmt.addSAMLApp(req, null).then((resp) => resp.toObject());
}
public regenerateAPIClientSecret(appId: string, projectId: string): Promise<RegenerateAPIClientSecretResponse.AsObject> {
const req = new RegenerateAPIClientSecretRequest();
req.setAppId(appId);
@ -2312,6 +2307,10 @@ export class ManagementService {
return this.grpcService.mgmt.updateAPIAppConfig(req, null).then((resp) => resp.toObject());
}
public updateSAMLAppConfig(req: UpdateSAMLAppConfigRequest): Promise<UpdateSAMLAppConfigResponse.AsObject> {
return this.grpcService.mgmt.updateSAMLAppConfig(req, null).then((resp) => resp.toObject());
}
public removeApp(projectId: string, appId: string): Promise<RemoveAppResponse.AsObject> {
const req = new RemoveAppRequest();
req.setAppId(appId);

View File

@ -1609,9 +1609,9 @@
"TITLE": "Anwendung",
"ID": "ID",
"DESCRIPTION": "Hier kannst Du Deine Applikationen bearbeiten und deren Konfiguration anpassen.",
"CREATE_OIDC": "OIDC-Anwendung",
"CREATE_OIDC_DESC_TITLE": "Gebe die Daten der Anwendung Schritt für Schritt ein.",
"CREATE_OIDC_DESC_SUB": "Es wird automatisch eine empfohlene Konfiguration generiert.",
"CREATE": "Applikation erstellen",
"CREATE_DESC_TITLE": "Gebe die Daten der Anwendung Schritt für Schritt ein.",
"CREATE_DESC_SUB": "Es wird automatisch eine empfohlene Konfiguration generiert.",
"STATE": "Status",
"DATECREATED": "Erstellt",
"DATECHANGED": "Geändert",
@ -1664,6 +1664,10 @@
"ADDITIONALORIGINSDESC": "Wenn sie zusätzliche Origins definieren wollen, die nicht den Redirect URIs gleichzusetzen sind, können Sie dies hier tun.",
"ORIGINS": "Origins",
"NOTANORIGIN": "Der Angegebene Wert ist kein Origin.",
"PROSWITCH": "Konfigurator überspringen",
"NAMEANDTYPESECTION": "Name und Typ",
"TITLEFIRST": "Name der Applikation.",
"TYPETITLE": "Art der Anwendung",
"OIDC": {
"INFO": {
"ISSUER": "Issuer",
@ -1672,10 +1676,6 @@
"CURRENT": "Aktuelle Konfiguration",
"TOKENSECTIONTITLE": "AuthToken Optionen",
"REDIRECTSECTIONTITLE": "Weiterleitungseinstellungen",
"PROSWITCH": "Konfigurator überspringen",
"NAMEANDTYPESECTION": "Name und Typ",
"TITLEFIRST": "Gebe zuerst einen Namen ein.",
"TYPETITLE": "Welche Art von Anwendung möchtest Du erstellen?",
"REDIRECTTITLE": "Wohin soll nach dem Log-in weitergeleitet werden?",
"REDIRECTDESCRIPTIONWEB": "Die Weiterleitung muss mit https:// beginnen. http:// ist nur im Entwicklermodus zulässig.",
"REDIRECTDESCRIPTIONNATIVE": "Die Weiterleitung muss mit einem eigenen Protokoll, http://127.0.0.1, http://[::1] oder http://localhost beginnen.",
@ -1767,6 +1767,18 @@
"1": "Private Key JWT"
}
},
"SAML": {
"SELECTION": {
"TITLE": "SAML",
"DESCRIPTION": "SAML Applikationen"
},
"CONFIGSECTION": "SAML Konfiguration",
"URL": "Url des Metadata Files",
"OR": "oder",
"XML": "Metadata XML hochladen",
"METADATA": "Metadata",
"METADATAFROMFILE": "Metadata aus Datei"
},
"AUTHMETHODS": {
"CODE": {
"TITLE": "Code",

View File

@ -1609,9 +1609,9 @@
"TITLE": "Application",
"ID": "ID",
"DESCRIPTION": "Here you can edit your application data and it's configuration.",
"CREATE_OIDC": "OIDC Application",
"CREATE_OIDC_DESC_TITLE": "Enter Your Application Details Step by Step",
"CREATE_OIDC_DESC_SUB": "A recommended configuration will be automatically generated.",
"CREATE": "Create application",
"CREATE_DESC_TITLE": "Enter Your Application Details Step by Step",
"CREATE_DESC_SUB": "A recommended configuration will be automatically generated.",
"STATE": "Status",
"DATECREATED": "Created",
"DATECHANGED": "Changed",
@ -1664,6 +1664,10 @@
"ADDITIONALORIGINSDESC": "If you want to add additional Origins to your app which is not used as a redirect you can do that here.",
"ORIGINS": "Origins",
"NOTANORIGIN": "The entered value is not an origin",
"PROSWITCH": "I'm a pro. Skip this wizard.",
"NAMEANDTYPESECTION": "Name and Type",
"TITLEFIRST": "Name of the application",
"TYPETITLE": "Type of application",
"OIDC": {
"INFO": {
"ISSUER": "Issuer",
@ -1672,10 +1676,6 @@
"CURRENT": "Current Config",
"TOKENSECTIONTITLE": "AuthToken Options",
"REDIRECTSECTIONTITLE": "Redirect Settings",
"PROSWITCH": "I'm a pro. Skip this wizard.",
"NAMEANDTYPESECTION": "Name and Type",
"TITLEFIRST": "Insert a name first.",
"TYPETITLE": "What type of application do you want to create?",
"REDIRECTTITLE": "Specify the URIs where the login will redirect to.",
"POSTREDIRECTTITLE": "This is the redirect URI after logout.",
"REDIRECTDESCRIPTIONWEB": "Redirect URIs must begin with https://. http:// is only valid with enabled development mode.",
@ -1767,6 +1767,18 @@
"1": "Private Key JWT"
}
},
"SAML": {
"SELECTION": {
"TITLE": "SAML",
"DESCRIPTION": "SAML Applications"
},
"CONFIGSECTION": "SAML Configuration",
"URL": "Url where Metadata file is located",
"OR": "or",
"XML": "Upload Metadata XML",
"METADATA": "Metadata",
"METADATAFROMFILE": "Metadata from File"
},
"AUTHMETHODS": {
"CODE": {
"TITLE": "Code",

View File

@ -1609,9 +1609,9 @@
"TITLE": "Applicazione",
"ID": "ID",
"DESCRIPTION": "Qui puoi modificare i dati della tua applicazione e la sua configurazione.",
"CREATE_OIDC": "Applicazione OIDC",
"CREATE_OIDC_DESC_TITLE": "Inserisci i dettagli della tua applicazione passo dopo passo",
"CREATE_OIDC_DESC_SUB": "Una configurazione raccomandata sar\u00e0 generata automaticamente.",
"CREATE": "Crea Applicazione",
"CREATE_DESC_TITLE": "Inserisci i dettagli della tua applicazione passo dopo passo",
"CREATE_DESC_SUB": "Una configurazione raccomandata sar\u00e0 generata automaticamente.",
"STATE": "Stato",
"DATECREATED": "Creato",
"DATECHANGED": "Cambiato",
@ -1664,6 +1664,10 @@
"ADDITIONALORIGINSDESC": "Se vuoi aggiungere ulteriori Origini alla tua app che non \u00e8 usata come reindirizzamento puoi farlo qui.",
"ORIGINS": "Origini",
"NOTANORIGIN": "Il valore inserito non \u00e8 un'origine",
"PROSWITCH": "Sono un professionista. Salta questo passo.",
"NAMEANDTYPESECTION": "Nome e tipo",
"TITLEFIRST": "Nome dell' applicazione",
"TYPETITLE": "Che tipo di applicazione vuoi creare?",
"OIDC": {
"INFO": {
"ISSUER": "Issuer",
@ -1672,10 +1676,6 @@
"CURRENT": "Configurazione attuale",
"TOKENSECTIONTITLE": "Opzioni AuthToken",
"REDIRECTSECTIONTITLE": "Impostazioni di reindirizzamento",
"PROSWITCH": "Sono un professionista. Salta questo passo.",
"NAMEANDTYPESECTION": "Nome e tipo",
"TITLEFIRST": "Inserisci un nome.",
"TYPETITLE": "Che tipo di applicazione vuoi creare?",
"REDIRECTTITLE": "Specifica gli URI a cui il login sar\u00e0 reindirizzato.",
"POSTREDIRECTTITLE": "Questo \u00e8 l'URI di reindirizzamento dopo il logout.",
"REDIRECTDESCRIPTIONWEB": "Gli URI di reindirizzamento devono iniziare con https://. http:// \u00e8 valido solo con la modalit\u00e0 di sviluppo abilitata (DEV Mode).",
@ -1767,6 +1767,18 @@
"1": "Private Key JWT"
}
},
"SAML": {
"SELECTION": {
"TITLE": "SAML",
"DESCRIPTION": "Applicazioni SAMML"
},
"CONFIGSECTION": "Configurazione SAML",
"URL": "URL in cui si trova il file di metadati",
"OR": "o",
"XML": "Carica Metadata XML",
"METADATA": "Metadata",
"METADATAFROMFILE": "Metadati dal file"
},
"AUTHMETHODS": {
"CODE": {
"TITLE": "Code",

View File

@ -1,4 +1,5 @@
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/xml/xml';
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

View File

@ -33,6 +33,7 @@ title: zitadel/app.proto
| name | string | - | |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) config.oidc_config | OIDCConfig | - | |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) config.api_config | APIConfig | - | |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) config.saml_config | SAMLConfig | - | |
@ -88,6 +89,18 @@ title: zitadel/app.proto
### SAMLConfig
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) metadata.metadata_xml | bytes | - | |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) metadata.metadata_url | string | - | |
## Enums

View File

@ -1254,6 +1254,19 @@ Returns a new generated secret if needed (Depending on the configuration)
POST: /projects/{project_id}/apps/oidc
### AddSAMLApp
> **rpc** AddSAMLApp([AddSAMLAppRequest](#addsamlapprequest))
[AddSAMLAppResponse](#addsamlappresponse)
Adds a new saml service provider
Returns a entityID
POST: /projects/{project_id}/apps/saml
### AddAPIApp
> **rpc** AddAPIApp([AddAPIAppRequest](#addapiapprequest))
@ -1292,6 +1305,18 @@ Changes the configuration of the oidc client
PUT: /projects/{project_id}/apps/{app_id}/oidc_config
### UpdateSAMLAppConfig
> **rpc** UpdateSAMLAppConfig([UpdateSAMLAppConfigRequest](#updatesamlappconfigrequest))
[UpdateSAMLAppConfigResponse](#updatesamlappconfigresponse)
Changes the configuration of the saml application
PUT: /projects/{project_id}/apps/{app_id}/saml_config
### UpdateAPIAppConfig
> **rpc** UpdateAPIAppConfig([UpdateAPIAppConfigRequest](#updateapiappconfigrequest))
@ -2994,7 +3019,7 @@ This is an empty request
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| primary_color | string | - | string.max_len: 50<br /> |
| hide_login_name_suffix | bool | hides the org suffix on the login form if the scope \"urn:zitadel:iam:org:domain:primary:{domainname}\" is set. Details about this [scope in](../openidoauth/scopes) | |
| hide_login_name_suffix | bool | hides the org suffix on the login form if the scope \"urn:zitadel:iam:org:domain:primary:{domainname}\" is set. Details about this scope in https://docs.zitadel.com/concepts#Reserved_Scopes | |
| warn_color | string | - | string.max_len: 50<br /> |
| background_color | string | - | string.max_len: 50<br /> |
| font_color | string | - | string.max_len: 50<br /> |
@ -3670,6 +3695,32 @@ This is an empty request
### AddSAMLAppRequest
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| project_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
| name | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) metadata.metadata_xml | bytes | - | bytes.max_len: 500000<br /> |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) metadata.metadata_url | string | - | string.max_len: 200<br /> |
### AddSAMLAppResponse
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| app_id | string | - | |
| details | zitadel.v1.ObjectDetails | - | |
### AddSecondFactorToLoginPolicyRequest
@ -8309,6 +8360,31 @@ This is an empty request
### UpdateSAMLAppConfigRequest
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| project_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
| app_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) metadata.metadata_xml | bytes | - | bytes.max_len: 500000<br /> |
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) metadata.metadata_url | string | - | string.max_len: 200<br /> |
### UpdateSAMLAppConfigResponse
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| details | zitadel.v1.ObjectDetails | - | |
### UpdateUserGrantRequest

View File

@ -33,7 +33,7 @@ title: zitadel/policy.proto
| details | zitadel.v1.ObjectDetails | - | |
| primary_color | string | hex value for primary color | |
| is_default | bool | defines if the organisation's admin changed the policy | |
| hide_login_name_suffix | bool | hides the org suffix on the login form if the scope \"urn:zitadel:iam:org:domain:primary:{domainname}\" is set. Details about this [scope in](../openidoauth/scopes) | |
| hide_login_name_suffix | bool | hides the org suffix on the login form if the scope \"urn:zitadel:iam:org:domain:primary:{domainname}\" is set. Details about this scope in https://docs.zitadel.com/concepts#Reserved_Scopes | |
| warn_color | string | hex value for secondary color | |
| background_color | string | hex value for background color | |
| font_color | string | hex value for font color | |

View File

@ -0,0 +1,60 @@
---
title: Endpoints
---
## SAML 2.0 Metadata
The SAML Metadata is located within the issuer domain. This would give us {your_domain}/saml/v2/metadata.
This metadata contains all the information defined in the spec.
**Link to
spec.** [Metadata for the OASIS Security Assertion Markup Language (SAML) V2.0 Errata Composite](https://www.oasis-open.org/committees/download.php/35391/sstc-saml-metadata-errata-2.0-wd-04-diff.pdf)
## Certificate Endpoint
{your_domain}/saml/v2/certificate
The certificate endpoint provides the certificate which is used to sign the responses for download, for easier use with
different service providers which want the certificate separately instead of inside the metadata.
## SSO Endpoint
{your_domain}/saml/v2/SSO
The SSO endpoint is the starting point for all initial user authentications. The user agent (browser) will be redirected
to this endpoint to authenticate the user.
Supported on this endpoint or currently `urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect`
or `urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST` bindings.
**Link to
spec.** [Bindings for the OASIS Security Assertion Markup Language (SAML) V2.0 Errata Composite](https://www.oasis-open.org/committees/download.php/35387/sstc-saml-bindings-errata-2.0-wd-05-diff.pdf)
### Required request parameters
| Parameter | Description |
|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| RelayState | ID to associate the exchange with the original request. |
| SAMLRequest | The request made to the SAML IDP. (base64 encoded) |
| SigAlg | Algorithm used to sign the request, only if binding is 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' as signature has to be provided es separate parameter. (base64 encoded) |
| Signature | Signature of the request as parameter with 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' binding. (base64 encoded) |
### Successful Response
Depending on the content of the request the response comes back in the requested binding, but the content is the same.
| Parameter | Description |
|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| RelayState | ID to associate the exchange with the original request. |
| SAMLResponse | The response form the SAML IDP. (base64 encoded) |
| SigAlg | Algorithm used to sign the response, only if binding is 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' as signature has to be provided es separate parameter. (base64 encoded) |
| Signature | Signature of the response as parameter with 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' binding. (base64 encoded) |
### Error Response
Regardless of the error, the used http error code will be '200', which represents a successful request. Whereas the
response will contain a StatusCode include a message which provides more information if an error occurred.
**Link to
spec** [Assertions and Protocols for the OASIS Security Assertion Markup Language (SAML) V2.0 Errata Composite](https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf)

View File

@ -40,6 +40,7 @@ All APIs of ZITADEL are always available as gRCP, gRPC-web and REST service.
The only exception is the [OpenID Connect & OAuth](/docs/apis/openidoauth/endpoints) and [Asset API](/docs/apis/introduction#assets) due their unique nature.
- [OpenID Connect & OAuth](/docs/apis/openidoauth/endpoints) - allows to request authentication and authorization of ZITADEL
- [SAML](/docs/apis/saml/endpoints) - allows to request authentication and authorization of ZITADEL through the SAML standard
- [Authentication API](/docs/apis/introduction#authentication) - allow a user to do operation in its own context
- [Management API](/docs/apis/introduction#management) - allows an admin or machine to manage the ZITADEL resources on an organization level
- [Administration API](/docs/apis/introduction#administration) - allows an admin or machine to manage the ZITADEL resources on an instance level

View File

@ -0,0 +1,96 @@
---
title: Connect with Atlassian through SAML 2.0
---
This guide shows how to enable login with ZITADEL on Atlassian.
It covers how to:
- create and configure the application in your project
- create and configure the connection in Atlassian Access
Prerequisites:
- existing ZITADEL Instance, if not present follow [this guide](../../guides/start/quickstart)
- existing ZITADEL Organization, if not present follow [this guide](../../guides/manage/console/organizations)
- existing ZITADEL project, if not present follow the first 3 steps [here](../../guides/manage/console/projects)
- existing Atlassian Access setup, including verified domain
> We have to switch between ZITADEL and Atlassian. If the headings begin with "ZITADEL" switch to the ZITADEL
> Console and
> if the headings start with "Atlassian" please switch to the Atlassian Admin GUI.
## **Atlassian**: Create a new external identity provider
Please follow the instructions on [Atlassian's support page](https://support.atlassian.com/security-and-access-policies/docs/configure-saml-single-sign-on-with-an-identity-provider/) to configure a SAML identity provider for SSO.
The following instructions give you a quick overview of the most important steps.
Login to Atlassian's security center and select Identity providers.
Select the option to Set up SAML single sign-on.
![Security Center](/img/saml/atlassian/atlassian-01.png)
For Identity Provider select "Other provider" and enter a Directory Name.
![Add identity provider](/img/saml/atlassian/atlassian-02.png)
Follow the wizard.
Fill in the following information:
- `Identity provider Entity ID`: {your_instance_domain}/saml/v2/metadata
- `Identity provider SSO URL`: {your_instance_domain}/saml/v2/SSO
- `Public x509 certificate`: You need to download and paste the value of the certificate from {your_instance_domain}/saml/v2/certificate
![Add SAML details](/img/saml/atlassian/atlassian-03.png)
Create a new .xml file with the following minimal SAML metadata contents:
```xml
<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${ENTITYID}">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol urn:oasis:names:tc:SAML:1.1:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${ACSURL}" index="0"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
```
Set or replace the variables with the values from the next screen as follows:
- `${ENTITYID}`: Copy the value from "Service provider entity URL"
- `${ACSURL}`: Copy the value from "Service provider assertion consumer service URL"
![Copy URLs](/img/saml/atlassian/atlassian-04.png)
## **ZITADEL**: Create the application
In your existing project:
Press the "+"-button to add an application
![Project](/img/saml/zitadel/project.png)
Fill in a name for the application and chose the SAML type, then click "Continue".
![New Application](/img/saml/zitadel/application_saml.png)
Either fill in the URL where ZITADEL can read the metadata from, or upload the metadata XML directly, then click "Continue".
![Add Metadata to Application](/img/saml/zitadel/application_saml_metadata.png)
Check your application, if everything is correct, press "Create".
![Create Application](/img/saml/zitadel/application_saml_create.png)
## **Atlassian**: Setup authentication policies
Under Authentication policies, select "Edit" on the directory that you have created.
Then check the box "Enforce single sign-on" and confirm by clicking "Update".
![Authentication policies](/img/saml/atlassian/atlassian-05.png)
Add members to your policy.
![Add Members](/img/saml/atlassian/atlassian-06.png)
## Verify configuration
Now you should be all set to verify your setup:
- Create an user in ZITADEL with the same email address as a member in your authentication policy.
- In a new browser session go to https://id.atlassian.com
- Enter the user's email address
- You should be redirected to ZITADEL's Login screen
- Enter the email address and password
- Continue and you should be redirected back to Atlassian

View File

@ -1,5 +1,5 @@
---
title: Connect with Auth0
title: Connect with Auth0 through OIDC
---
This guide shows how to enable login with ZITADEL on Auth0.
@ -16,7 +16,7 @@ Prerequisites:
- existing ZITADEL project, if not present follow the first 3 steps [here](../../guides/manage/console/projects)
- existing Auth0 tenant as described [here](https://auth0.com/docs/get-started/auth0-overview/create-tenants)
> We have to switch between ZITADEL and a Auth0. If the headings begin with "ZITADEL" switch to the ZITADEL Console and if the headings start with Auth0 please switch to the Auth0 GUI.
> We have to switch between ZITADEL and a Auth0. If the headings begin with "ZITADEL" switch to the ZITADEL Console and if the headings start with "Auth0" please switch to the Auth0 GUI.
## **Auth0**: Create a new connection

View File

@ -0,0 +1,78 @@
---
title: Connect with Auth0 through SAML 2.0
---
This guide shows how to enable login with ZITADEL on Auth0.
It covers how to:
- create and configure the application in your project
- create and configure the connection in your Auth0 tenant
Prerequisites:
- existing ZITADEL Instance, if not present follow [this guide](../../guides/start/quickstart)
- existing ZITADEL Organization, if not present follow [this guide](../../guides/manage/console/organizations)
- existing ZITADEL project, if not present follow the first 3 steps [here](../../guides/manage/console/projects)
- existing Auth0 tenant as described [here](https://auth0.com/docs/get-started/auth0-overview/create-tenants)
> We have to switch between ZITADEL and a Auth0. If the headings begin with "ZITADEL" switch to the ZITADEL Console and
> if the headings start with "Auth0" please switch to the Auth0 GUI.
## **Auth0**: Create a new connection
In Authentication -> Enterprise
![Navigation Authentication Enterprise](/img/saml/auth0/auth_enterprise.png)
1. Press the "+" button right to "SAML"
![Enterprise Connections](/img/saml/auth0/enterprise_connections.png)
2. Fill out the fields as follows in the SAML Connection:
![New SAML Connection](/img/saml/auth0/connection.png)
This includes:
- a unique "Connection name"
- the "Sign In URL"
- the "Sign Out URL"
- used "User ID Attribute"
- the definition how the request should be signed
- which binding should be used to call ZITADEL
All the information is filled out as an example, and to connect with any other environment you only have to change the
used domain, for example "example.com" with "zitadel.cloud".
Lastly, upload the certificate used to sign the reponses, provided for you under the
URL {your_instance_domain}/saml/v2/certificate.
Then just press the button "Create" and the connection on Auth0 is configured.
## **ZITADEL**: Create the application
You need to upload the SAML metadata to ZITADEL for it to recognize this newly created connection.
[Under this link](https://auth0.com/docs/authenticate/protocols/saml/saml-identity-provider-configuration-settings) are
all necessary information to correctly fill out the metadata or download the metadata-file directly under the
URL https://YOUR_AUTH0_DOMAIN/samlp/metadata?connection=YOUR_CONNECTION_NAME, which in this example would
be https://example.auth0.com/samlp/metadata?connection=SAML-ZITADEL.
In your existing project:
1. Press the "+"-button to add an application
![Project](/img/saml/zitadel/project.png)
2. Fill in a name for the application and chose the SAML type, then click "Continue".
![New Application](/img/saml/zitadel/application_saml.png)
3. Either fill in the URL where ZITADEL can read the metadata from, or upload the metadata XML directly, then click "
Continue".
![Add Metadata to Application](/img/saml/zitadel/application_saml_metadata.png)
4. Check your application, if everything is correct, press "Create".
![Create Application](/img/saml/zitadel/application_saml_create.png)
Everything on the side of ZITADEL is done if the application is correctly created.
## **Auth0**: Try the connection
To then test the connection you only have to press "Try" on the created connection in the Authentication -> Enterprise
screen.
![Authentication Enterprise Try](/img/saml/auth0/auth_enterprise_try.png)
To further customize the requests you can also customize the SAML communication as
described [here](https://auth0.com/docs/authenticate/protocols/saml/saml-configuration/customize-saml-assertions)

View File

@ -0,0 +1,78 @@
---
title: Connect with AWS through SAML 2.0
---
This guide shows how to enable login with ZITADEL on AWS SSO.
It covers how to:
- create and configure the application in your project
- create and configure the connection in your AWS SSO external IDP
Prerequisites:
- existing ZITADEL Instance, if not present follow [this guide](../../guides/start/quickstart)
- existing ZITADEL Organization, if not present follow [this guide](../../guides/manage/console/organizations)
- existing ZITADEL project, if not present follow the first 3 steps [here](../../guides/manage/console/projects)
- prerequisites on AWS side [here](https://docs.aws.amazon.com/singlesignon/latest/userguide/prereqs.html).
- enabled AWS SSO [here](https://docs.aws.amazon.com/singlesignon/latest/userguide/step1.html?icmpid=docs_sso_console)
> We have to switch between ZITADEL and a AWS. If the headings begin with "ZITADEL" switch to the ZITADEL Console and if
> the headings start with "AWS" please switch to the AWS GUI.
## **AWS**: Change to external identity provider ZITADEL
As you have activated SSO you still have the possibility to use AWS itself to manage the users, but you can also use a
Microsoft AD or an external IDP.
Described [here](https://docs.aws.amazon.com/singlesignon/latest/userguide/manage-your-identity-source-idp.html) how you
can connect to ZITADEL as a SAML2 IDP.
1. Chose the External identity provider:
![Choose identity source](/img/saml/aws/change_idp.png)
2. Download the metadata file, to provide ZITADEL with all the information it needs, and save the AWS SSO Sign-in URL,
which you use to login afterwards.
3. Fill out the fields as follows, to provide AWS with all the information it needs:
![Configure external identity provider](/img/saml/aws/configure_idp.png)
To connect to another environment, change the domains, for example if you would use ZITADEL under the domain "
example.com" you would have the URLs "https://accounts.example.com/saml/SSO"
and "https://accounts.exmaple.com/saml/metadata".
4. Download the ZITADEL-used certificate to sign the responses, so that AWS can validation the signature.
You can download the certificate from following
URL: {your_instance_domain}/saml/v2/certificate
5. Then upload the ".crt"-file to AWS and click "next".
6. Lastly, accept to confirm the change and ZITADEL is used as the external identity provider for AWS SSO to provide
connectivity to your AWS Accounts.
As for how the SSO users are then connected to the AWS accounts, you can find more information in the AWS documentation,
for example [here](https://docs.aws.amazon.com/singlesignon/latest/userguide/useraccess.html).
## **ZITADEL**: Create the application
The metadata used in this part is from "Change to external identity provider ZITADEL" step 2.
In your existing project:
1. Press the "+"-button to add an application
![Project](/img/saml/zitadel/project.png)
2. Fill in a name for the application and chose the SAML type, then click "Continue".
![New Application](/img/saml/zitadel/application_saml.png)
3. Either fill in the URL where ZITADEL can read the metadata from, or upload the metadata XML directly, then click "
Continue".
![Add Metadata to Application](/img/saml/zitadel/application_saml_metadata.png)
4. Check your application, if everything is correct, press "Create".
![Create Application](/img/saml/zitadel/application_saml_create.png)
Everything on the side of ZITADEL is done if the application is correctly created.
## **AWS**: Test the connection
The result, you can now login to you AWS account through your ZITADEL-login with the AWS SSO Sign-in URL, which you
should have saved in "Change to external identity provider ZITADEL" step 2.

View File

@ -1,5 +1,5 @@
---
title: Connect with AzureAD
title: Connect with AzureAD through OIDC
---
## AzureAD Tenant as Identity Provider for ZITADEL

View File

@ -0,0 +1,67 @@
---
title: Connect with Gitlab through SAML 2.0
---
This guide shows how to enable login with ZITADEL on Gitlab.
It covers how to:
- create and configure the application in your project
- create and configure the connection in Gitlab SaaS
Prerequisites:
- existing ZITADEL Instance, if not present follow [this guide](../../guides/start/quickstart)
- existing ZITADEL Organization, if not present follow [this guide](../../guides/manage/console/organizations)
- existing ZITADEL project, if not present follow the first 3 steps [here](../../guides/manage/console/projects)
- existing Gitlab SaaS Setup in the premium tier
> We have to switch between ZITADEL and Gitlab. If the headings begin with "ZITADEL" switch to the ZITADEL
> Console and
> if the headings start with "Gitlab" please switch to the Gitlab GUI.
## **Gitlab**: Create a new external identity provider
Please follow the instructions on [Gitlab docs](https://docs.gitlab.com/ee/user/group/saml_sso/index.html) to configure a SAML identity provider for SSO.
The following instructions give you a quick overview of the most important steps.
[Open the group](https://gitlab.com/dashboard/groups) to which you want to add the SSO configuration.
Select on the menu Settings and then SAML SSO.
Copy `GitLab metadata URL` for the next step.
![Add identity provider](/img/saml/gitlab/gitlab-01.png)
## **ZITADEL**: Create the application
In your existing project:
Press the "+"-button to add an application
![Project](/img/saml/zitadel/project.png)
Fill in a name for the application and chose the SAML type, then click "Continue".
![New Application](/img/saml/zitadel/application_saml.png)
Enter the URL from before, then click "Continue".
![Add Metadata to Application](/img/saml/zitadel/application_saml_metadata.png)
Check your application, if everything is correct, press "Create".
![Create Application](/img/saml/zitadel/application_saml_create.png)
## **Gitlab**: Configuration
Complete the configuration as follows:
- `Identity provider single sign-on URL`: {your_instance_domain}/saml/v2/SSO
- `Certificate fingerprint`: You need to download the certificate from {your_instance_domain}/saml/v2/certificate and create a SHA1 fingerprint
Save the changes.
![Filled in values](/img/saml/gitlab/gitlab-02.png)
## **Gitlab**: Verify SAML configuration
Once you saved the changes, click on the button "Verify SAML configuration".
You should be redirected to ZITADEL.
Login with your user.
After that you should be redirected back to GitLab and you can inspect the Response Output.
![Validate Setup](/img/saml/gitlab/gitlab-03.png)

View File

@ -0,0 +1,84 @@
---
title: Connect with Ping Identity through SAML 2.0
---
This guide shows how to enable login with ZITADEL on Auth0.
It covers how to:
- create and configure the application in your project
- create and configure the connection in your Ping Identity tenant
Prerequisites:
- existing ZITADEL Instance, if not present follow [this guide](../../guides/start/quickstart)
- existing ZITADEL Organization, if not present follow [this guide](../../guides/manage/console/organizations)
- existing ZITADEL project, if not present follow the first 3 steps [here](../../guides/manage/console/projects)
- existing Pingidentity environment [here](https://docs.pingidentity.com/bundle/pingone/page/wqe1564020490538.html)
> We have to switch between ZITADEL and Ping Identity. If the headings begin with "ZITADEL" switch to the ZITADEL
> Console and
> if the headings start with "Ping" please switch to the PingIdentity GUI.
## **Ping**: Create a new external identity provider
To add an
additional [external identity provider](https://docs.pingidentity.com/bundle/pingone/page/jvz1567784210191.html), you
can follow the instructions [here](https://docs.pingidentity.com/bundle/pingone/page/ovy1567784211297.html)
1. As described you have to create a new provider, with a unique identifier:
![Create IDP Profile](/img/saml/pingidentity/create_idp_profile.png)
We recommend activating signing the auth request whenever possible:
![Configure PingOne Connection](/img/saml/pingidentity/conf_connection.png)
2. Manually enter the necessary information:
- SSO Endpoint, for example https://accounts.example.com/saml/SSO
- IDP EntityID, for example https://accounts.example.com/saml/metadata
- Binding, which is a decision which you can take yourself, we recommend HTTP POST as it has fewer restrictions
- Import certificate, provided from the certificate endpoint
![Configure IDP Connection](/img/saml/pingidentity/conf_idp_connection.png)
Everything you need to know about the attribute mapping you can find
in [Ping Identity's documentation](https://docs.pingidentity.com/bundle/pingone/page/pwv1567784207915.html)
3. With this you have defined to connection to ZITADEL as an external IDP, next is the policy to use ZITADEL as an IDP
to
connect to an application. The "How to" for that can be
found [here](https://docs.pingidentity.com/bundle/pingone/page/zqd1616600404402.html).
## **ZITADEL**: Create the application
To add the connection to ZITADEL you have to build the metadata, which should minimalistic look like this, the necessary
information can be found on the External IDPs page under "P1Connection" and "IDP Configuration" :
```xml
ENTITYID="PINGONE (SP) ENTITY ID"
ACSURL="ACS ENDPOINT"
<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${ENTITYID}">
<md:SPSSODescriptor
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol urn:oasis:names:tc:SAML:1.1:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${ACSURL}"
index="0"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
```
![Identity Providers P1 Connection](/img/saml/pingidentity/idp_p1_connection.png)
![Identity Providers IDP Configuration](/img/saml/pingidentity/idp_idp_configuration.png)
In your existing project:
1. Press the "+"-button to add an application
![Project](/img/saml/zitadel/project.png)
2. Fill in a name for the application and chose the SAML type, then click "Continue".
![New Application](/img/saml/zitadel/application_saml.png)
3. Either fill in the URL where ZITADEL can read the metadata from, or upload the metadata XML directly, then click "
Continue".
![Add Metadata to Application](/img/saml/zitadel/application_saml_metadata.png)
4. Check your application, if everything is correct, press "Create".
![Create Application](/img/saml/zitadel/application_saml_create.png)
Everything on the side of ZITADEL is done if the application is correctly created.

View File

@ -115,17 +115,43 @@ module.exports = {
label: "Integrate",
collapsed: false,
items: [
"guides/integrate/login-users",
"guides/integrate/identity-brokering",
{
type: "category",
label: "Access ZITADEL APIs",
collapsed: false,
items: [
"guides/integrate/serviceusers",
"guides/integrate/access-zitadel-apis",
"guides/integrate/access-zitadel-system-api",
"guides/integrate/authenticated-mongodb-charts",
"guides/integrate/auth0",
"guides/integrate/azuread",
"guides/integrate/gitlab-self-hosted",
"guides/integrate/login-users",
"guides/integrate/serviceusers",
"guides/integrate/export-and-import",
],
},
{
type: "category",
label: "OpenID Connect 1.0 Clients",
collapsed: false,
items: [
"guides/integrate/oauth-recommended-flows",
"guides/integrate/export-and-import"
"guides/integrate/auth0-oidc",
"guides/integrate/azuread-oidc",
"guides/integrate/authenticated-mongodb-charts",
"guides/integrate/gitlab-self-hosted",
],
},
{
type: "category",
label: "SAML 2.0 Clients",
collapsed: false,
items: [
"guides/integrate/auth0-saml",
"guides/integrate/aws-saml",
"guides/integrate/pingidentity-saml",
"guides/integrate/atlassian-saml",
"guides/integrate/gitlab-saml",
],
},
],
},
{
@ -205,6 +231,14 @@ module.exports = {
"apis/openidoauth/grant-types",
],
},
{
type: "category",
label: "SAML",
collapsed: false,
items: [
"apis/saml/endpoints",
],
},
{
type: "category",
label: "Rate Limits",

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/static/img/saml/aws/change_idp.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
docs/static/img/saml/zitadel/project.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -26,7 +26,7 @@ export function ensureSomethingExists(api: apiCallProperties, searchPath: string
}
})
}).then((data) => {
awaitDesired(30, (entity) => !!entity, data.initialSequence, api, searchPath, find)
awaitDesired(90, (entity) => !!entity, data.initialSequence, api, searchPath, find)
return cy.wrap<number>(data.id)
})
}
@ -49,7 +49,7 @@ export function ensureSomethingDoesntExist(api: apiCallProperties, searchPath: s
return sRes.sequence
})
}).then((initialSequence) => {
awaitDesired(30, (entity) => !entity , initialSequence, api, searchPath, find)
awaitDesired(90, (entity) => !entity , initialSequence, api, searchPath, find)
return null
})
}
@ -77,7 +77,10 @@ function searchSomething(api: apiCallProperties, searchPath: string, find: (enti
function awaitDesired(trials: number, expectEntity: (entity: any) => boolean, initialSequence: number, api: apiCallProperties, searchPath: string, find: (entity: any) => boolean) {
searchSomething(api, searchPath, find).then(resp => {
if (!expectEntity(resp.entity) || resp.sequence <= initialSequence) {
const foundExpectedEntity = expectEntity(resp.entity)
const foundExpectedSequence = resp.sequence > initialSequence
if (!foundExpectedEntity || !foundExpectedSequence) {
expect(trials, `trying ${trials} more times`).to.be.greaterThan(0);
cy.wait(1000)
awaitDesired(trials - 1, expectEntity, initialSequence, api, searchPath, find)

9
go.mod
View File

@ -48,11 +48,12 @@ require (
github.com/sony/sonyflake v1.0.0
github.com/spf13/cobra v1.3.0
github.com/spf13/viper v1.10.1
github.com/stretchr/testify v1.7.1
github.com/stretchr/testify v1.8.0
github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203
github.com/ttacon/libphonenumber v1.2.1
github.com/zitadel/logging v0.3.4
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.5
github.com/zitadel/saml v0.0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.27.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.27.0
go.opentelemetry.io/otel v1.2.0
@ -82,6 +83,8 @@ require (
cloud.google.com/go/trace v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/amdonov/xmlsig v0.1.0 // indirect
github.com/beevik/etree v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
@ -135,6 +138,7 @@ require (
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/go-types v0.0.0-20210723172823-2deba1f80ba7 // indirect
github.com/kevinburke/rest v0.0.0-20210506044642-5611499aa33c // indirect
@ -161,6 +165,7 @@ require (
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/rs/xid v1.2.1 // indirect
github.com/russellhaering/goxmldsig v1.2.0 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spf13/afero v1.8.1 // indirect
@ -184,7 +189,7 @@ require (
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.7 // indirect
)

28
go.sum
View File

@ -95,6 +95,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5
github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc=
github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/amdonov/xmlsig v0.1.0 h1:i0iQ3neKLmUhcfIRgiiR3eRPKgXZj+n5lAfqnfKoeXI=
github.com/amdonov/xmlsig v0.1.0/go.mod h1:jTR/jO0E8fSl/cLvMesP+RjxyV4Ux4WL1Ip64ZnQpA0=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
@ -108,6 +110,8 @@ github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6l
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/benbjohnson/clock v1.2.0 h1:9Re3G2TWxkE06LdMWMpcY6KV81GLXMGiYpPYUPkFAws=
github.com/benbjohnson/clock v1.2.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@ -560,6 +564,8 @@ github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@ -600,8 +606,9 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@ -735,6 +742,7 @@ github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -785,6 +793,9 @@ github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqn
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM=
@ -792,6 +803,8 @@ github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russellhaering/goxmldsig v1.2.0 h1:Y6GTTc9Un5hCxSzVz4UIWQ/zuVwDvzJk80guqzwx6Vg=
github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
@ -845,14 +858,16 @@ github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5J
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 h1:1SWXcTphBQjYGWRRxLFIAR1LVtQEj4eR7xPtyeOVM/c=
@ -886,6 +901,12 @@ github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM
github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0=
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.5 h1:dP+6SheVtpF4T/oql6mJoqou8jlW3J/9NCTYnEpKgpM=
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.5/go.mod h1:uoJw5Xc6HXfnQbNZiLbld9dED0/8UMu0M4gOipTRZBA=
github.com/zitadel/saml v0.0.4 h1:xz97KyKD3mrQcIEKi0aUPyNX834xm3p1ToO9HK/vVeY=
github.com/zitadel/saml v0.0.4/go.mod h1:DIy/ln32rNYv/bIBA8uOB6Y2JmxjZldDYBeMNn7YyeQ=
github.com/zitadel/saml v0.0.5 h1:ufLE0MeWe2SLGGkSWbY3J20xxYtAL57IddeDsyMqCuM=
github.com/zitadel/saml v0.0.5/go.mod h1:DIy/ln32rNYv/bIBA8uOB6Y2JmxjZldDYBeMNn7YyeQ=
github.com/zitadel/saml v0.0.6 h1:avcOanSNd0x4jkn/+MsXIGQpJOvwqkk+slGJVIhqk84=
github.com/zitadel/saml v0.0.6/go.mod h1:DIy/ln32rNYv/bIBA8uOB6Y2JmxjZldDYBeMNn7YyeQ=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
@ -1479,8 +1500,9 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.21.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=

View File

@ -67,6 +67,16 @@ func (s *Server) AddOIDCApp(ctx context.Context, req *mgmt_pb.AddOIDCAppRequest)
ComplianceProblems: project_grpc.ComplianceProblemsToLocalizedMessages(app.Compliance.Problems),
}, nil
}
func (s *Server) AddSAMLApp(ctx context.Context, req *mgmt_pb.AddSAMLAppRequest) (*mgmt_pb.AddSAMLAppResponse, error) {
app, err := s.command.AddSAMLApplication(ctx, AddSAMLAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}
return &mgmt_pb.AddSAMLAppResponse{
AppId: app.AppID,
Details: object_grpc.AddToDetailsPb(app.Sequence, app.ChangeDate, app.ResourceOwner),
}, nil
}
func (s *Server) AddAPIApp(ctx context.Context, req *mgmt_pb.AddAPIAppRequest) (*mgmt_pb.AddAPIAppResponse, error) {
appSecretGenerator, err := s.query.InitHashGenerator(ctx, domain.SecretGeneratorTypeAppSecret, s.passwordHashAlg)
@ -109,6 +119,20 @@ func (s *Server) UpdateOIDCAppConfig(ctx context.Context, req *mgmt_pb.UpdateOID
}, nil
}
func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAMLAppConfigRequest) (*mgmt_pb.UpdateSAMLAppConfigResponse, error) {
config, err := s.command.ChangeSAMLApplication(ctx, UpdateSAMLAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}
return &mgmt_pb.UpdateSAMLAppConfigResponse{
Details: object_grpc.ChangeToDetailsPb(
config.Sequence,
config.ChangeDate,
config.ResourceOwner,
),
}, nil
}
func (s *Server) UpdateAPIAppConfig(ctx context.Context, req *mgmt_pb.UpdateAPIAppConfigRequest) (*mgmt_pb.UpdateAPIAppConfigResponse, error) {
config, err := s.command.ChangeAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID)
if err != nil {

View File

@ -59,6 +59,17 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) *domain.OIDCApp {
}
}
func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) *domain.SAMLApp {
return &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: req.ProjectId,
},
AppName: req.Name,
Metadata: req.GetMetadataXml(),
MetadataURL: req.GetMetadataUrl(),
}
}
func AddAPIAppRequestToDomain(app *mgmt_pb.AddAPIAppRequest) *domain.APIApp {
return &domain.APIApp{
ObjectRoot: models.ObjectRoot{
@ -98,6 +109,17 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest)
}
}
func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) *domain.SAMLApp {
return &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: app.ProjectId,
},
AppID: app.AppId,
Metadata: app.GetMetadataXml(),
MetadataURL: app.GetMetadataUrl(),
}
}
func UpdateAPIAppConfigRequestToDomain(app *mgmt_pb.UpdateAPIAppConfigRequest) *domain.APIApp {
return &domain.APIApp{
ObjectRoot: models.ObjectRoot{

View File

@ -33,6 +33,9 @@ func AppConfigToPb(app *query.App) app_pb.AppConfig {
if app.OIDCConfig != nil {
return AppOIDCConfigToPb(app.OIDCConfig)
}
if app.SAMLConfig != nil {
return AppSAMLConfigToPb(app.SAMLConfig)
}
return AppAPIConfigToPb(app.APIConfig)
}
@ -61,6 +64,14 @@ func AppOIDCConfigToPb(app *query.OIDCApp) *app_pb.App_OidcConfig {
}
}
func AppSAMLConfigToPb(app *query.SAMLApp) app_pb.AppConfig {
return &app_pb.App_SamlConfig{
SamlConfig: &app_pb.SAMLConfig{
Metadata: &app_pb.SAMLConfig_MetadataXml{MetadataXml: app.Metadata},
},
}
}
func AppAPIConfigToPb(app *query.APIApp) app_pb.AppConfig {
return &app_pb.App_ApiConfig{
ApiConfig: &app_pb.APIConfig{

View File

@ -0,0 +1,99 @@
package saml
import (
"context"
"time"
"github.com/zitadel/saml/pkg/provider/models"
"github.com/zitadel/saml/pkg/provider/xml/samlp"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
)
var _ models.AuthRequestInt = &AuthRequest{}
type AuthRequest struct {
*domain.AuthRequest
}
func (a *AuthRequest) GetApplicationID() string {
return a.ApplicationID
}
func (a *AuthRequest) GetID() string {
return a.ID
}
func (a *AuthRequest) GetRelayState() string {
return a.TransferState
}
func (a *AuthRequest) GetAccessConsumerServiceURL() string {
return a.CallbackURI
}
func (a *AuthRequest) GetNameID() string {
return a.UserName
}
func (a *AuthRequest) saml() *domain.AuthRequestSAML {
return a.Request.(*domain.AuthRequestSAML)
}
func (a *AuthRequest) GetAuthRequestID() string {
return a.saml().ID
}
func (a *AuthRequest) GetBindingType() string {
return a.saml().BindingType
}
func (a *AuthRequest) GetIssuer() string {
return a.saml().Issuer
}
func (a *AuthRequest) GetIssuerName() string {
return a.saml().IssuerName
}
func (a *AuthRequest) GetDestination() string {
return a.saml().Destination
}
func (a *AuthRequest) GetCode() string {
return a.saml().Code
}
func (a *AuthRequest) GetUserID() string {
return a.UserID
}
func (a *AuthRequest) GetUserName() string {
return a.UserName
}
func (a *AuthRequest) Done() bool {
for _, step := range a.PossibleSteps {
if step.Type() == domain.NextStepRedirectToCallback {
return true
}
}
return false
}
func AuthRequestFromBusiness(authReq *domain.AuthRequest) (_ models.AuthRequestInt, err error) {
if _, ok := authReq.Request.(*domain.AuthRequestSAML); !ok {
return nil, errors.ThrowInvalidArgument(nil, "SAML-Hbz7A", "auth request is not of type saml")
}
return &AuthRequest{authReq}, nil
}
func CreateAuthRequestToBusiness(ctx context.Context, authReq *samlp.AuthnRequestType, acsUrl, protocolBinding, applicationID, relayState, userAgentID string) *domain.AuthRequest {
return &domain.AuthRequest{
CreationDate: time.Now(),
AgentID: userAgentID,
ApplicationID: applicationID,
CallbackURI: acsUrl,
TransferState: relayState,
InstanceID: authz.GetInstance(ctx).InstanceID(),
Request: &domain.AuthRequestSAML{
ID: authReq.Id,
BindingType: protocolBinding,
Code: "",
Issuer: authReq.Issuer.Text,
IssuerName: authReq.Issuer.SPProvidedID,
Destination: authReq.Destination,
},
}
}

View File

@ -0,0 +1,202 @@
package saml
import (
"context"
"fmt"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/saml/pkg/provider/key"
"gopkg.in/square/go-jose.v2"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/keypair"
)
const (
locksTable = "projections.locks"
signingKey = "signing_key"
samlUser = "SAML"
retryBackoff = 500 * time.Millisecond
retryCount = 3
lockDuration = retryCount * retryBackoff * 5
gracefulPeriod = 10 * time.Minute
)
type CertificateAndKey struct {
algorithm jose.SignatureAlgorithm
id string
key interface{}
certificate interface{}
}
func (c *CertificateAndKey) SignatureAlgorithm() jose.SignatureAlgorithm {
return c.algorithm
}
func (c *CertificateAndKey) Key() interface{} {
return c.key
}
func (c *CertificateAndKey) Certificate() interface{} {
return c.certificate
}
func (c *CertificateAndKey) ID() string {
return c.id
}
func (p *Storage) GetCertificateAndKey(ctx context.Context, usage domain.KeyUsage) (certAndKey *key.CertificateAndKey, err error) {
err = retry(func() error {
certAndKey, err = p.getCertificateAndKey(ctx, usage)
if err != nil {
return err
}
if certAndKey == nil {
return errors.ThrowInternal(err, "SAML-8u01nks", "no certificate found")
}
return nil
})
return certAndKey, err
}
func (p *Storage) getCertificateAndKey(ctx context.Context, usage domain.KeyUsage) (*key.CertificateAndKey, error) {
certs, err := p.query.ActiveCertificates(ctx, time.Now().Add(gracefulPeriod), usage)
if err != nil {
return nil, err
}
if len(certs.Certificates) > 0 {
return p.certificateToCertificateAndKey(selectCertificate(certs.Certificates))
}
var sequence uint64
if certs.LatestSequence != nil {
sequence = certs.LatestSequence.Sequence
}
return nil, p.refreshCertificate(ctx, usage, sequence)
}
func (p *Storage) refreshCertificate(
ctx context.Context,
usage domain.KeyUsage,
sequence uint64,
) error {
ok, err := p.ensureIsLatestCertificate(ctx, sequence)
if err != nil {
logging.WithError(err).Error("could not ensure latest key")
return err
}
if !ok {
logging.Warn("view not up to date, retrying later")
return err
}
err = p.lockAndGenerateCertificateAndKey(ctx, usage, sequence)
logging.OnError(err).Warn("could not create signing key")
return nil
}
func (p *Storage) ensureIsLatestCertificate(ctx context.Context, sequence uint64) (bool, error) {
maxSequence, err := p.getMaxKeySequence(ctx)
if err != nil {
return false, fmt.Errorf("error retrieving new events: %w", err)
}
return sequence == maxSequence, nil
}
func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage domain.KeyUsage, sequence uint64) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx = setSAMLCtx(ctx)
errs := p.locker.Lock(ctx, lockDuration, authz.GetInstance(ctx).InstanceID())
err, ok := <-errs
if err != nil || !ok {
if errors.IsErrorAlreadyExists(err) {
return nil
}
logging.OnError(err).Warn("initial lock failed")
return err
}
switch usage {
case domain.KeyUsageSAMLMetadataSigning, domain.KeyUsageSAMLResponseSinging:
certAndKey, err := p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLCA)
if err != nil {
return fmt.Errorf("error while reading ca certificate: %w", err)
}
if certAndKey.Key == nil || certAndKey.Certificate == nil {
return fmt.Errorf("has no ca certificate")
}
switch usage {
case domain.KeyUsageSAMLMetadataSigning:
return p.command.GenerateSAMLMetadataCertificate(setSAMLCtx(ctx), p.certificateAlgorithm, certAndKey.Key, certAndKey.Certificate)
case domain.KeyUsageSAMLResponseSinging:
return p.command.GenerateSAMLResponseCertificate(setSAMLCtx(ctx), p.certificateAlgorithm, certAndKey.Key, certAndKey.Certificate)
default:
return fmt.Errorf("unknown usage")
}
case domain.KeyUsageSAMLCA:
return p.command.GenerateSAMLCACertificate(setSAMLCtx(ctx), p.certificateAlgorithm)
default:
return fmt.Errorf("unknown certificate usage")
}
}
func (p *Storage) getMaxKeySequence(ctx context.Context) (uint64, error) {
return p.eventstore.LatestSequence(ctx,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence).
ResourceOwner(authz.GetInstance(ctx).InstanceID()).
AddQuery().
AggregateTypes(keypair.AggregateType).
Builder(),
)
}
func (p *Storage) certificateToCertificateAndKey(certificate query.Certificate) (_ *key.CertificateAndKey, err error) {
keyData, err := crypto.Decrypt(certificate.Key(), p.encAlg)
if err != nil {
return nil, err
}
privateKey, err := crypto.BytesToPrivateKey(keyData)
if err != nil {
return nil, err
}
cert, err := crypto.BytesToCertificate(certificate.Certificate())
if err != nil {
return nil, err
}
return &key.CertificateAndKey{
Key: privateKey,
Certificate: cert,
}, nil
}
func selectCertificate(certs []query.Certificate) query.Certificate {
return certs[len(certs)-1]
}
func setSAMLCtx(ctx context.Context) context.Context {
return authz.SetCtxData(ctx, authz.CtxData{UserID: samlUser, OrgID: authz.GetInstance(ctx).InstanceID()})
}
func retry(retryable func() error) (err error) {
for i := 0; i < retryCount; i++ {
err = retryable()
if err == nil {
return nil
}
time.Sleep(retryBackoff)
}
return err
}

View File

@ -0,0 +1,102 @@
package saml
import (
"context"
"database/sql"
"fmt"
"net/http"
"github.com/zitadel/saml/pkg/provider"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/auth/repository"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
)
const (
HandlerPrefix = "/saml/v2"
)
type Config struct {
ProviderConfig *provider.Config
}
func NewProvider(
ctx context.Context,
conf Config,
externalSecure bool,
command *command.Commands,
query *query.Queries,
repo repository.Repository,
encAlg crypto.EncryptionAlgorithm,
certEncAlg crypto.EncryptionAlgorithm,
es *eventstore.Eventstore,
projections *sql.DB,
instanceHandler,
userAgentCookie func(http.Handler) http.Handler,
) (*provider.Provider, error) {
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount}
provStorage, err := newStorage(
command,
query,
repo,
encAlg,
certEncAlg,
es,
projections,
)
if err != nil {
return nil, err
}
options := []provider.Option{
provider.WithHttpInterceptors(
middleware.MetricsHandler(metricTypes),
middleware.TelemetryHandler(),
middleware.NoCacheInterceptor().Handler,
instanceHandler,
userAgentCookie,
http_utils.CopyHeadersToContext,
),
}
if !externalSecure {
options = append(options, provider.WithAllowInsecure())
}
return provider.NewProvider(
ctx,
provStorage,
HandlerPrefix,
conf.ProviderConfig,
options...,
)
}
func newStorage(
command *command.Commands,
query *query.Queries,
repo repository.Repository,
encAlg crypto.EncryptionAlgorithm,
certEncAlg crypto.EncryptionAlgorithm,
es *eventstore.Eventstore,
projections *sql.DB,
) (*Storage, error) {
return &Storage{
encAlg: encAlg,
certEncAlg: certEncAlg,
locker: crdb.NewLocker(projections, locksTable, signingKey),
eventstore: es,
repo: repo,
command: command,
query: query,
defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID),
}, nil
}

View File

@ -0,0 +1,187 @@
package saml
import (
"context"
"time"
"github.com/zitadel/saml/pkg/provider"
"github.com/zitadel/saml/pkg/provider/key"
"github.com/zitadel/saml/pkg/provider/models"
"github.com/zitadel/saml/pkg/provider/serviceprovider"
"github.com/zitadel/saml/pkg/provider/xml/samlp"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/auth/repository"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
var _ provider.EntityStorage = &Storage{}
var _ provider.IdentityProviderStorage = &Storage{}
var _ provider.AuthStorage = &Storage{}
var _ provider.UserStorage = &Storage{}
type Storage struct {
certChan <-chan interface{}
defaultCertificateLifetime time.Duration
currentCACertificate query.Certificate
currentMetadataCertificate query.Certificate
currentResponseCertificate query.Certificate
locker crdb.Locker
certificateAlgorithm string
encAlg crypto.EncryptionAlgorithm
certEncAlg crypto.EncryptionAlgorithm
eventstore *eventstore.Eventstore
repo repository.Repository
command *command.Commands
query *query.Queries
defaultLoginURL string
}
func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*serviceprovider.ServiceProvider, error) {
app, err := p.query.AppBySAMLEntityID(ctx, entityID)
if err != nil {
return nil, err
}
return serviceprovider.NewServiceProvider(
app.ID,
&serviceprovider.Config{
Metadata: app.SAMLConfig.Metadata,
},
p.defaultLoginURL,
)
}
func (p *Storage) GetEntityIDByAppID(ctx context.Context, appID string) (string, error) {
app, err := p.query.AppByID(ctx, appID)
if err != nil {
return "", err
}
return app.SAMLConfig.EntityID, nil
}
func (p *Storage) Health(context.Context) error {
return nil
}
func (p *Storage) GetCA(ctx context.Context) (*key.CertificateAndKey, error) {
return p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLCA)
}
func (p *Storage) GetMetadataSigningKey(ctx context.Context) (*key.CertificateAndKey, error) {
return p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLMetadataSigning)
}
func (p *Storage) GetResponseSigningKey(ctx context.Context) (*key.CertificateAndKey, error) {
return p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLResponseSinging)
}
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, errors.ThrowPreconditionFailed(nil, "SAML-sd436", "no user agent id")
}
authRequest := CreateAuthRequestToBusiness(ctx, req, acsUrl, protocolBinding, applicationID, relayState, userAgentID)
resp, err := p.repo.CreateAuthRequest(ctx, authRequest)
if err != nil {
return nil, err
}
return AuthRequestFromBusiness(resp)
}
func (p *Storage) AuthRequestByID(ctx context.Context, id string) (_ models.AuthRequestInt, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok {
return nil, errors.ThrowPreconditionFailed(nil, "SAML-D3g21", "no user agent id")
}
resp, err := p.repo.AuthRequestByIDCheckLoggedIn(ctx, id, userAgentID)
if err != nil {
return nil, err
}
return AuthRequestFromBusiness(resp)
}
func (p *Storage) SetUserinfoWithUserID(ctx context.Context, userinfo models.AttributeSetter, userID string, attributes []int) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
user, err := p.query.GetUserByID(ctx, true, userID)
if err != nil {
return err
}
setUserinfo(user, userinfo, attributes)
return nil
}
func (p *Storage) SetUserinfoWithLoginName(ctx context.Context, userinfo models.AttributeSetter, loginName string, attributes []int) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
loginNameSQ, err := query.NewUserLoginNamesSearchQuery(loginName)
if err != nil {
return err
}
user, err := p.query.GetUser(ctx, true, loginNameSQ)
if err != nil {
return err
}
setUserinfo(user, userinfo, attributes)
return nil
}
func setUserinfo(user *query.User, userinfo models.AttributeSetter, attributes []int) {
if len(attributes) == 0 {
userinfo.SetUsername(user.PreferredLoginName)
userinfo.SetUserID(user.ID)
if user.Human == nil {
return
}
userinfo.SetEmail(user.Human.Email)
userinfo.SetSurname(user.Human.LastName)
userinfo.SetGivenName(user.Human.FirstName)
userinfo.SetFullName(user.Human.DisplayName)
return
}
for _, attribute := range attributes {
switch attribute {
case provider.AttributeEmail:
if user.Human != nil {
userinfo.SetEmail(user.Human.Email)
}
case provider.AttributeSurname:
if user.Human != nil {
userinfo.SetSurname(user.Human.LastName)
}
case provider.AttributeFullName:
if user.Human != nil {
userinfo.SetFullName(user.Human.DisplayName)
}
case provider.AttributeGivenName:
if user.Human != nil {
userinfo.SetGivenName(user.Human.FirstName)
}
case provider.AttributeUsername:
userinfo.SetUsername(user.PreferredLoginName)
case provider.AttributeUserID:
userinfo.SetUserID(user.ID)
}
}
}

View File

@ -37,6 +37,7 @@ type Login struct {
externalSecure bool
consolePath string
oidcAuthCallbackURL func(context.Context, string) string
samlAuthCallbackURL func(context.Context, string) string
idpConfigAlg crypto.EncryptionAlgorithm
userCodeAlg crypto.EncryptionAlgorithm
}
@ -61,10 +62,12 @@ func CreateLogin(config Config,
staticStorage static.Storage,
consolePath string,
oidcAuthCallbackURL func(context.Context, string) string,
samlAuthCallbackURL func(context.Context, string) string,
externalSecure bool,
userAgentCookie,
issuerInterceptor,
instanceHandler,
oidcInstanceHandler,
samlInstanceHandler mux.MiddlewareFunc,
assetCache mux.MiddlewareFunc,
userCodeAlg crypto.EncryptionAlgorithm,
idpConfigAlg crypto.EncryptionAlgorithm,
@ -73,6 +76,7 @@ func CreateLogin(config Config,
login := &Login{
oidcAuthCallbackURL: oidcAuthCallbackURL,
samlAuthCallbackURL: samlAuthCallbackURL,
externalSecure: externalSecure,
consolePath: consolePath,
command: command,
@ -91,7 +95,7 @@ func CreateLogin(config Config,
cacheInterceptor := createCacheInterceptor(config.Cache.MaxAge, config.Cache.SharedMaxAge, assetCache)
security := middleware.SecurityHeaders(csp(), login.cspErrorHandler)
login.router = CreateRouter(login, statikFS, middleware.TelemetryHandler(IgnoreInstanceEndpoints...), instanceHandler, csrfInterceptor, cacheInterceptor, security, userAgentCookie, issuerInterceptor)
login.router = CreateRouter(login, statikFS, middleware.TelemetryHandler(IgnoreInstanceEndpoints...), oidcInstanceHandler, samlInstanceHandler, csrfInterceptor, cacheInterceptor, security, userAgentCookie, issuerInterceptor)
login.renderer = CreateRenderer(HandlerPrefix, statikFS, staticStorage, config.LanguageCookieName)
login.parser = form.NewParser()
return login, nil

View File

@ -4,6 +4,7 @@ import (
"net/http"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
const (
@ -43,11 +44,26 @@ func (l *Login) renderSuccessAndCallback(w http.ResponseWriter, r *http.Request,
userData: l.getUserData(r, authReq, "Login Successful", errID, errMessage),
}
if authReq != nil {
data.RedirectURI = l.oidcAuthCallbackURL(r.Context(), "") //the id will be set via the html (maybe change this with the login refactoring)
//the id will be set via the html (maybe change this with the login refactoring)
if _, ok := authReq.Request.(*domain.AuthRequestOIDC); ok {
data.RedirectURI = l.oidcAuthCallbackURL(r.Context(), "")
} else if _, ok := authReq.Request.(*domain.AuthRequestSAML); ok {
data.RedirectURI = l.samlAuthCallbackURL(r.Context(), "")
}
}
l.renderer.RenderTemplate(w, r, l.getTranslator(r.Context(), authReq), l.renderer.Templates[tmplLoginSuccess], data, nil)
}
func (l *Login) redirectToCallback(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest) {
http.Redirect(w, r, l.oidcAuthCallbackURL(r.Context(), authReq.ID), http.StatusFound)
var callback string
switch authReq.Request.(type) {
case *domain.AuthRequestOIDC:
callback = l.oidcAuthCallbackURL(r.Context(), authReq.ID)
case *domain.AuthRequestSAML:
callback = l.samlAuthCallbackURL(r.Context(), authReq.ID)
default:
l.renderInternalError(w, r, authReq, caos_errs.ThrowInternal(nil, "LOGIN-rhjQF", "Errors.AuthRequest.RequestTypeNotSupported"))
return
}
http.Redirect(w, r, callback, http.StatusFound)
}

View File

@ -97,12 +97,12 @@ type orgViewProvider interface {
}
type userGrantProvider interface {
ProjectByOIDCClientID(context.Context, string) (*query.Project, error)
ProjectByClientID(context.Context, string) (*query.Project, error)
UserGrantsByProjectAndUserID(context.Context, string, string) ([]*query.UserGrant, error)
}
type projectProvider interface {
ProjectByOIDCClientID(context.Context, string) (*query.Project, error)
ProjectByClientID(context.Context, string) (*query.Project, error)
OrgProjectMappingByIDs(orgID, projectID, instanceID string) (*project_view_model.OrgProjectMapping, error)
}
@ -122,7 +122,7 @@ func (repo *AuthRequestRepo) CreateAuthRequest(ctx context.Context, request *dom
return nil, err
}
request.ID = reqID
project, err := repo.ProjectProvider.ProjectByOIDCClientID(ctx, request.ApplicationID)
project, err := repo.ProjectProvider.ProjectByClientID(ctx, request.ApplicationID)
if err != nil {
return nil, err
}
@ -1048,6 +1048,9 @@ func (repo *AuthRequestRepo) getLoginTexts(ctx context.Context, aggregateID stri
}
func (repo *AuthRequestRepo) hasSucceededPage(ctx context.Context, request *domain.AuthRequest, provider applicationProvider) (bool, error) {
if _, ok := request.Request.(*domain.AuthRequestOIDC); !ok {
return false, nil
}
app, err := provider.AppByOIDCClientID(ctx, request.ApplicationID)
if err != nil {
return false, err
@ -1257,8 +1260,8 @@ func linkingIDPConfigExistingInAllowedIDPs(linkingUsers []*domain.ExternalUser,
func userGrantRequired(ctx context.Context, request *domain.AuthRequest, user *user_model.UserView, userGrantProvider userGrantProvider) (_ bool, err error) {
var project *query.Project
switch request.Request.Type() {
case domain.AuthRequestTypeOIDC:
project, err = userGrantProvider.ProjectByOIDCClientID(ctx, request.ApplicationID)
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML:
project, err = userGrantProvider.ProjectByClientID(ctx, request.ApplicationID)
if err != nil {
return false, err
}
@ -1278,8 +1281,8 @@ func userGrantRequired(ctx context.Context, request *domain.AuthRequest, user *u
func projectRequired(ctx context.Context, request *domain.AuthRequest, projectProvider projectProvider) (_ bool, err error) {
var project *query.Project
switch request.Request.Type() {
case domain.AuthRequestTypeOIDC:
project, err = projectProvider.ProjectByOIDCClientID(ctx, request.ApplicationID)
case domain.AuthRequestTypeOIDC, domain.AuthRequestTypeSAML:
project, err = projectProvider.ProjectByClientID(ctx, request.ApplicationID)
if err != nil {
return false, err
}

View File

@ -22,6 +22,10 @@ import (
user_view_model "github.com/zitadel/zitadel/internal/user/repository/view/model"
)
var (
testNow = time.Now()
)
type mockViewNoUserSession struct{}
func (m *mockViewNoUserSession) UserSessionByIDs(string, string, string) (*user_view_model.UserSessionView, error) {
@ -191,7 +195,7 @@ type mockUserGrants struct {
userGrants int
}
func (m *mockUserGrants) ProjectByOIDCClientID(ctx context.Context, s string) (*query.Project, error) {
func (m *mockUserGrants) ProjectByClientID(ctx context.Context, s string) (*query.Project, error) {
return &query.Project{ProjectRoleCheck: m.roleCheck}, nil
}
@ -208,7 +212,7 @@ type mockProject struct {
projectCheck bool
}
func (m *mockProject) ProjectByOIDCClientID(ctx context.Context, s string) (*query.Project, error) {
func (m *mockProject) ProjectByClientID(ctx context.Context, s string) (*query.Project, error) {
return &query.Project{HasProjectCheck: m.projectCheck}, nil
}
@ -615,8 +619,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"passwordless verified, email not verified, email verification step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordlessVerification: time.Now().Add(-5 * time.Minute),
MultiFactorVerification: time.Now().Add(-5 * time.Minute),
PasswordlessVerification: testNow.Add(-5 * time.Minute),
MultiFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -667,7 +671,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"external user (no external verification), external login step",
fields{
userSessionViewProvider: &mockViewUserSession{
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
IsEmailVerified: true,
@ -699,8 +703,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"external user (external verification set), callback",
fields{
userSessionViewProvider: &mockViewUserSession{
ExternalLoginVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
ExternalLoginVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
IsEmailVerified: true,
@ -759,8 +763,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"external user (no password check needed), callback",
fields{
userSessionViewProvider: &mockViewUserSession{
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
ExternalLoginVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
ExternalLoginVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -795,7 +799,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"password verified, passwordless set up, mfa not verified, mfa check step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -829,7 +833,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"mfa not verified, mfa check step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -862,8 +866,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"external user, mfa not verified, mfa check step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
ExternalLoginVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
ExternalLoginVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -898,8 +902,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"password change required and email verified, password change step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -931,8 +935,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"email not verified and no password change required, mail verification step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -961,8 +965,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"email not verified and password change required, mail verification step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -992,8 +996,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"email verified and no password change required, redirect to callback step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1027,8 +1031,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"prompt none, checkLoggedIn true and authenticated, redirect to callback step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1063,8 +1067,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"prompt none, checkLoggedIn true, authenticated and native, login succeeded step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1099,8 +1103,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"prompt none, checkLoggedIn true, authenticated and required user grants missing, grant required step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1137,8 +1141,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"prompt none, checkLoggedIn true, authenticated and required user grants exist, redirect to callback step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1176,8 +1180,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"prompt none, checkLoggedIn true, authenticated and required project missing, project required step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1214,8 +1218,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"prompt none, checkLoggedIn true, authenticated and required project exist, redirect to callback step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1253,7 +1257,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"linking users, password step",
fields{
userSessionViewProvider: &mockViewUserSession{
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1287,8 +1291,8 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
"linking users, linking step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
SecondFactorVerification: time.Now().UTC().Add(-5 * time.Minute),
PasswordVerification: testNow.Add(-5 * time.Minute),
SecondFactorVerification: testNow.Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
@ -1463,7 +1467,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
user: &user_model.UserView{
HumanView: &user_model.HumanView{
MFAMaxSetUp: domain.MFALevelNotSetUp,
MFAInitSkipped: time.Now().UTC(),
MFAInitSkipped: testNow,
},
},
},
@ -1486,7 +1490,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
OTPState: user_model.MFAStateReady,
},
},
userSession: &user_model.UserSessionView{SecondFactorVerification: time.Now().UTC().Add(-5 * time.Hour)},
userSession: &user_model.UserSessionView{SecondFactorVerification: testNow.Add(-5 * time.Hour)},
},
nil,
true,
@ -1569,7 +1573,7 @@ func TestAuthRequestRepo_mfaSkippedOrSetUp(t *testing.T) {
user: &user_model.UserView{
HumanView: &user_model.HumanView{
MFAMaxSetUp: -1,
MFAInitSkipped: time.Now().UTC().Add(-10 * time.Hour),
MFAInitSkipped: testNow.Add(-10 * time.Hour),
},
},
request: &domain.AuthRequest{
@ -1587,7 +1591,7 @@ func TestAuthRequestRepo_mfaSkippedOrSetUp(t *testing.T) {
user: &user_model.UserView{
HumanView: &user_model.HumanView{
MFAMaxSetUp: -1,
MFAInitSkipped: time.Now().UTC().Add(-40 * 24 * time.Hour),
MFAInitSkipped: testNow.Add(-40 * 24 * time.Hour),
},
},
request: &domain.AuthRequest{
@ -1645,13 +1649,13 @@ func Test_userSessionByIDs(t *testing.T) {
"error user events, old view model state",
args{
userProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
},
user: &user_model.UserView{ID: "id", HumanView: &user_model.HumanView{FirstName: "FirstName"}},
eventProvider: &mockEventErrUser{},
},
&user_model.UserSessionView{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
SecondFactorVerification: time.Time{},
MultiFactorVerification: time.Time{},
},
@ -1661,7 +1665,7 @@ func Test_userSessionByIDs(t *testing.T) {
"new user events but error, old view model state",
args{
userProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
},
agentID: "agentID",
user: &user_model.UserView{ID: "id", HumanView: &user_model.HumanView{FirstName: "FirstName"}},
@ -1669,12 +1673,12 @@ func Test_userSessionByIDs(t *testing.T) {
&es_models.Event{
AggregateType: user_repo.AggregateType,
Type: es_models.EventType(user_repo.UserV1MFAOTPCheckSucceededType),
CreationDate: time.Now().UTC().Round(1 * time.Second),
CreationDate: testNow,
},
},
},
&user_model.UserSessionView{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
SecondFactorVerification: time.Time{},
MultiFactorVerification: time.Time{},
},
@ -1684,7 +1688,7 @@ func Test_userSessionByIDs(t *testing.T) {
"new user events but other agentID, old view model state",
args{
userProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
},
agentID: "agentID",
user: &user_model.UserView{ID: "id"},
@ -1692,7 +1696,7 @@ func Test_userSessionByIDs(t *testing.T) {
&es_models.Event{
AggregateType: user_repo.AggregateType,
Type: es_models.EventType(user_repo.UserV1MFAOTPCheckSucceededType),
CreationDate: time.Now().UTC().Round(1 * time.Second),
CreationDate: testNow,
Data: func() []byte {
data, _ := json.Marshal(&user_es_model.AuthRequest{UserAgentID: "otherID"})
return data
@ -1701,7 +1705,7 @@ func Test_userSessionByIDs(t *testing.T) {
},
},
&user_model.UserSessionView{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
SecondFactorVerification: time.Time{},
MultiFactorVerification: time.Time{},
},
@ -1711,7 +1715,7 @@ func Test_userSessionByIDs(t *testing.T) {
"new user events, new view model state",
args{
userProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
},
agentID: "agentID",
user: &user_model.UserView{ID: "id", HumanView: &user_model.HumanView{FirstName: "FirstName"}},
@ -1719,7 +1723,7 @@ func Test_userSessionByIDs(t *testing.T) {
&es_models.Event{
AggregateType: user_repo.AggregateType,
Type: es_models.EventType(user_repo.UserV1MFAOTPCheckSucceededType),
CreationDate: time.Now().UTC().Round(1 * time.Second),
CreationDate: testNow,
Data: func() []byte {
data, _ := json.Marshal(&user_es_model.AuthRequest{UserAgentID: "agentID"})
return data
@ -1728,9 +1732,9 @@ func Test_userSessionByIDs(t *testing.T) {
},
},
&user_model.UserSessionView{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
SecondFactorVerification: time.Now().UTC().Round(1 * time.Second),
ChangeDate: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
SecondFactorVerification: testNow,
ChangeDate: testNow,
},
nil,
},
@ -1738,7 +1742,7 @@ func Test_userSessionByIDs(t *testing.T) {
"new user events (user deleted), precondition failed error",
args{
userProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Round(1 * time.Second),
PasswordVerification: testNow,
},
agentID: "agentID",
user: &user_model.UserView{ID: "id"},
@ -1816,7 +1820,7 @@ func Test_userByID(t *testing.T) {
&es_models.Event{
AggregateType: user_repo.AggregateType,
Type: es_models.EventType(user_repo.UserV1PasswordChangedType),
CreationDate: time.Now().UTC().Round(1 * time.Second),
CreationDate: testNow,
Data: nil,
},
},
@ -1842,7 +1846,7 @@ func Test_userByID(t *testing.T) {
&es_models.Event{
AggregateType: user_repo.AggregateType,
Type: es_models.EventType(user_repo.UserV1PasswordChangedType),
CreationDate: time.Now().UTC().Round(1 * time.Second),
CreationDate: testNow,
Data: func() []byte {
data, _ := json.Marshal(user_es_model.Password{ChangeRequired: false, Secret: &crypto.CryptoValue{}})
return data
@ -1851,13 +1855,13 @@ func Test_userByID(t *testing.T) {
},
},
&user_model.UserView{
ChangeDate: time.Now().UTC().Round(1 * time.Second),
ChangeDate: testNow,
State: user_model.UserStateActive,
UserName: "UserName",
HumanView: &user_model.HumanView{
PasswordSet: true,
PasswordChangeRequired: false,
PasswordChanged: time.Now().UTC().Round(1 * time.Second),
PasswordChanged: testNow,
FirstName: "FirstName",
},
},

View File

@ -1,10 +1,11 @@
package command
import (
"net/http"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/http"
api_http "github.com/zitadel/zitadel/internal/api/http"
sd "github.com/zitadel/zitadel/internal/config/systemdefaults"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
@ -23,6 +24,8 @@ import (
)
type Commands struct {
httpClient *http.Client
eventstore *eventstore.Eventstore
static static.Storage
idGenerator id.Generator
@ -40,14 +43,17 @@ type Commands struct {
applicationKeySize int
domainVerificationAlg crypto.EncryptionAlgorithm
domainVerificationGenerator crypto.Generator
domainVerificationValidator func(domain, token, verifier string, checkType http.CheckType) error
domainVerificationValidator func(domain, token, verifier string, checkType api_http.CheckType) error
multifactors domain.MultifactorConfigs
webauthnConfig *webauthn_helper.Config
keySize int
keyAlgorithm crypto.EncryptionAlgorithm
certificateAlgorithm crypto.EncryptionAlgorithm
certKeySize int
privateKeyLifetime time.Duration
publicKeyLifetime time.Duration
certificateLifetime time.Duration
}
func StartCommands(es *eventstore.Eventstore,
@ -64,7 +70,9 @@ func StartCommands(es *eventstore.Eventstore,
smsEncryption,
userEncryption,
domainVerificationEncryption,
oidcEncryption crypto.EncryptionAlgorithm,
oidcEncryption,
samlEncryption crypto.EncryptionAlgorithm,
httpClient *http.Client,
) (repo *Commands, err error) {
if externalDomain == "" {
return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified")
@ -78,15 +86,19 @@ func StartCommands(es *eventstore.Eventstore,
externalSecure: externalSecure,
externalPort: externalPort,
keySize: defaults.KeyConfig.Size,
certKeySize: defaults.KeyConfig.CertificateSize,
privateKeyLifetime: defaults.KeyConfig.PrivateKeyLifetime,
publicKeyLifetime: defaults.KeyConfig.PublicKeyLifetime,
certificateLifetime: defaults.KeyConfig.CertificateLifetime,
idpConfigEncryption: idpConfigEncryption,
smtpEncryption: smtpEncryption,
smsEncryption: smsEncryption,
userEncryption: userEncryption,
domainVerificationAlg: domainVerificationEncryption,
keyAlgorithm: oidcEncryption,
certificateAlgorithm: samlEncryption,
webauthnConfig: webAuthN,
httpClient: httpClient,
}
instance_repo.RegisterEventMappers(repo.eventstore)
@ -109,7 +121,7 @@ func StartCommands(es *eventstore.Eventstore,
}
repo.domainVerificationGenerator = crypto.NewEncryptionGenerator(defaults.DomainVerification.VerificationGenerator, repo.domainVerificationAlg)
repo.domainVerificationValidator = http.ValidateDomain
repo.domainVerificationValidator = api_http.ValidateDomain
return repo, nil
}

View File

@ -2,6 +2,10 @@ package command
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"math/big"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
@ -34,3 +38,138 @@ func (c *Commands) GenerateSigningKeyPair(ctx context.Context, algorithm string)
privateKeyExp, publicKeyExp))
return err
}
func (c *Commands) GenerateSAMLCACertificate(ctx context.Context, algorithm string) error {
now := time.Now().UTC()
after := now.Add(c.certificateLifetime)
randInt, err := rand.Int(rand.Reader, big.NewInt(1000))
if err != nil {
return err
}
privateCrypto, publicCrypto, certificateCrypto, err := crypto.GenerateEncryptedKeyPairWithCACertificate(c.certKeySize, c.keyAlgorithm, c.certificateAlgorithm, &crypto.CertificateInformations{
SerialNumber: randInt,
Organisation: []string{"ZITADEL"},
CommonName: "ZITADEL SAML CA",
NotBefore: now,
NotAfter: after,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
})
if err != nil {
return err
}
keyID, err := c.idGenerator.Next()
if err != nil {
return err
}
keyPairWriteModel := NewKeyPairWriteModel(keyID, authz.GetInstance(ctx).InstanceID())
keyAgg := KeyPairAggregateFromWriteModel(&keyPairWriteModel.WriteModel)
_, err = c.eventstore.Push(ctx,
keypair.NewAddedEvent(
ctx,
keyAgg,
domain.KeyUsageSAMLCA,
algorithm,
privateCrypto, publicCrypto,
after, after,
),
keypair.NewAddedCertificateEvent(
ctx,
keyAgg,
certificateCrypto,
after,
),
)
return err
}
func (c *Commands) GenerateSAMLResponseCertificate(ctx context.Context, algorithm string, caPrivateKey *rsa.PrivateKey, caCertificate []byte) error {
now := time.Now().UTC()
after := now.Add(c.certificateLifetime)
randInt, err := rand.Int(rand.Reader, big.NewInt(1000))
if err != nil {
return err
}
privateCrypto, publicCrypto, certificateCrypto, err := crypto.GenerateEncryptedKeyPairWithCertificate(c.certKeySize, c.keyAlgorithm, c.certificateAlgorithm, caPrivateKey, caCertificate, &crypto.CertificateInformations{
SerialNumber: randInt,
Organisation: []string{"ZITADEL"},
CommonName: "ZITADEL SAML response",
NotBefore: now,
NotAfter: after,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
})
if err != nil {
return err
}
keyID, err := c.idGenerator.Next()
if err != nil {
return err
}
keyPairWriteModel := NewKeyPairWriteModel(keyID, authz.GetInstance(ctx).InstanceID())
keyAgg := KeyPairAggregateFromWriteModel(&keyPairWriteModel.WriteModel)
_, err = c.eventstore.Push(ctx,
keypair.NewAddedEvent(
ctx,
keyAgg,
domain.KeyUsageSAMLResponseSinging,
algorithm,
privateCrypto, publicCrypto,
after, after,
),
keypair.NewAddedCertificateEvent(
ctx,
keyAgg,
certificateCrypto,
after,
),
)
return err
}
func (c *Commands) GenerateSAMLMetadataCertificate(ctx context.Context, algorithm string, caPrivateKey *rsa.PrivateKey, caCertificate []byte) error {
now := time.Now().UTC()
after := now.Add(c.certificateLifetime)
randInt, err := rand.Int(rand.Reader, big.NewInt(1000))
if err != nil {
return err
}
privateCrypto, publicCrypto, certificateCrypto, err := crypto.GenerateEncryptedKeyPairWithCertificate(c.certKeySize, c.keyAlgorithm, c.certificateAlgorithm, caPrivateKey, caCertificate, &crypto.CertificateInformations{
SerialNumber: randInt,
Organisation: []string{"ZITADEL"},
CommonName: "ZITADEL SAML metadata",
NotBefore: now,
NotAfter: after,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
})
if err != nil {
return err
}
keyID, err := c.idGenerator.Next()
if err != nil {
return err
}
keyPairWriteModel := NewKeyPairWriteModel(keyID, authz.GetInstance(ctx).InstanceID())
keyAgg := KeyPairAggregateFromWriteModel(&keyPairWriteModel.WriteModel)
_, err = c.eventstore.Push(ctx,
keypair.NewAddedEvent(
ctx,
keyAgg,
domain.KeyUsageSAMLMetadataSigning,
algorithm,
privateCrypto, publicCrypto,
after, after),
keypair.NewAddedCertificateEvent(
ctx,
keyAgg,
certificateCrypto,
after,
),
)
return err
}

View File

@ -13,6 +13,7 @@ type KeyPairWriteModel struct {
Algorithm string
PrivateKey *domain.Key
PublicKey *domain.Key
Certificate *domain.Key
}
func NewKeyPairWriteModel(aggregateID, resourceOwner string) *KeyPairWriteModel {
@ -42,6 +43,11 @@ func (wm *KeyPairWriteModel) Reduce() error {
Key: e.PublicKey.Key,
Expiry: e.PublicKey.Expiry,
}
case *keypair.AddedCertificateEvent:
wm.Certificate = &domain.Key{
Key: e.Certificate.Key,
Expiry: e.Certificate.Expiry,
}
}
}
return wm.WriteModel.Reduce()
@ -53,11 +59,10 @@ func (wm *KeyPairWriteModel) Query() *eventstore.SearchQueryBuilder {
AddQuery().
AggregateTypes(keypair.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(keypair.AddedEventType).
EventTypes(keypair.AddedEventType, keypair.AddedCertificateEventType).
Builder()
}
func KeyPairAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate {
return eventstore.AggregateFromWriteModel(wm, keypair.AggregateType, keypair.AggregateVersion)
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
@ -276,9 +277,20 @@ func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner s
if existingProject.State == domain.ProjectStateUnspecified || existingProject.State == domain.ProjectStateRemoved {
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound")
}
samlEntityIDsAgg, err := c.getSAMLEntityIdsWriteModelByProjectID(ctx, projectID, resourceOwner)
if err != nil {
return nil, err
}
uniqueConstraints := make([]*eventstore.EventUniqueConstraint, len(samlEntityIDsAgg.EntityIDs))
for i, entityID := range samlEntityIDsAgg.EntityIDs {
uniqueConstraints[i] = project.NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID.EntityID)
}
projectAgg := ProjectAggregateFromWriteModel(&existingProject.WriteModel)
events := []eventstore.Command{
project.NewProjectRemovedEvent(ctx, projectAgg, existingProject.Name),
project.NewProjectRemovedEvent(ctx, projectAgg, existingProject.Name, uniqueConstraints),
}
for _, grantID := range cascadingUserGrantIDs {
@ -309,3 +321,12 @@ func (c *Commands) getProjectWriteModelByID(ctx context.Context, projectID, reso
}
return projectWriteModel, nil
}
func (c *Commands) getSAMLEntityIdsWriteModelByProjectID(ctx context.Context, projectID, resourceOwner string) (*SAMLEntityIDsWriteModel, error) {
samlEntityIDsAgg := NewSAMLEntityIDsWriteModel(projectID, resourceOwner)
err := c.eventstore.FilterToQueryReducer(ctx, samlEntityIDsAgg)
if err != nil {
return nil, err
}
return samlEntityIDsAgg, nil
}

View File

@ -118,7 +118,13 @@ func (c *Commands) RemoveApplication(ctx context.Context, projectID, appID, reso
}
projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel)
pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationRemovedEvent(ctx, projectAgg, appID, existingApp.Name))
entityID := ""
samlWriteModel, err := c.getSAMLAppWriteModel(ctx, projectID, appID, resourceOwner)
if err == nil && samlWriteModel.State != domain.AppStateUnspecified && samlWriteModel.State != domain.AppStateRemoved && samlWriteModel.saml {
entityID = samlWriteModel.EntityID
}
pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationRemovedEvent(ctx, projectAgg, appID, existingApp.Name, entityID))
if err != nil {
return nil, err
}

View File

@ -0,0 +1,146 @@
package command
import (
"context"
"github.com/zitadel/saml/pkg/provider/xml"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/project"
)
func (c *Commands) AddSAMLApplication(ctx context.Context, application *domain.SAMLApp, resourceOwner string) (_ *domain.SAMLApp, err error) {
if application == nil || application.AggregateID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "PROJECT-35Fn0", "Errors.Project.App.Invalid")
}
_, err = c.getProjectByID(ctx, application.AggregateID, resourceOwner)
if err != nil {
return nil, caos_errs.ThrowPreconditionFailed(err, "PROJECT-3p9ss", "Errors.Project.NotFound")
}
addedApplication := NewSAMLApplicationWriteModel(application.AggregateID, resourceOwner)
projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel)
events, err := c.addSAMLApplication(ctx, projectAgg, application)
if err != nil {
return nil, err
}
addedApplication.AppID = application.AppID
pushedEvents, err := c.eventstore.Push(ctx, events...)
if err != nil {
return nil, err
}
err = AppendAndReduce(addedApplication, pushedEvents...)
if err != nil {
return nil, err
}
result := samlWriteModelToSAMLConfig(addedApplication)
return result, nil
}
func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstore.Aggregate, samlApp *domain.SAMLApp) (events []eventstore.Command, err error) {
if samlApp.AppName == "" || !samlApp.IsValid() {
return nil, caos_errs.ThrowInvalidArgument(nil, "PROJECT-1n9df", "Errors.Project.App.Invalid")
}
if samlApp.Metadata == nil && samlApp.MetadataURL == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "SAML-podix9", "Errors.Project.App.SAMLMetadataMissing")
}
if samlApp.MetadataURL != "" {
data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL)
if err != nil {
return nil, caos_errs.ThrowInvalidArgument(err, "SAML-wmqlo1", "Errors.Project.App.SAMLMetadataMissing")
}
samlApp.Metadata = data
}
entity, err := xml.ParseMetadataXmlIntoStruct(samlApp.Metadata)
if err != nil {
return nil, caos_errs.ThrowInvalidArgument(err, "SAML-bquso", "Errors.Project.App.SAMLMetadataFormat")
}
samlApp.AppID, err = c.idGenerator.Next()
if err != nil {
return nil, err
}
return []eventstore.Command{
project.NewApplicationAddedEvent(ctx, projectAgg, samlApp.AppID, samlApp.AppName),
project.NewSAMLConfigAddedEvent(ctx,
projectAgg,
samlApp.AppID,
string(entity.EntityID),
samlApp.Metadata,
samlApp.MetadataURL,
),
}, nil
}
func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) {
if !samlApp.IsValid() || samlApp.AppID == "" || samlApp.AggregateID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5n9fs", "Errors.Project.App.SAMLConfigInvalid")
}
existingSAML, err := c.getSAMLAppWriteModel(ctx, samlApp.AggregateID, samlApp.AppID, resourceOwner)
if err != nil {
return nil, err
}
if existingSAML.State == domain.AppStateUnspecified || existingSAML.State == domain.AppStateRemoved {
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-2n8uU", "Errors.Project.App.NotExisting")
}
if !existingSAML.IsSAML() {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-GBr35", "Errors.Project.App.IsNotSAML")
}
projectAgg := ProjectAggregateFromWriteModel(&existingSAML.WriteModel)
if samlApp.MetadataURL != "" {
data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL)
if err != nil {
return nil, caos_errs.ThrowInvalidArgument(err, "SAML-J3kg3", "Errors.Project.App.SAMLMetadataMissing")
}
samlApp.Metadata = data
}
entity, err := xml.ParseMetadataXmlIntoStruct(samlApp.Metadata)
if err != nil {
return nil, caos_errs.ThrowInvalidArgument(err, "SAML-3fk2b", "Errors.Project.App.SAMLMetadataFormat")
}
changedEvent, hasChanged, err := existingSAML.NewChangedEvent(
ctx,
projectAgg,
samlApp.AppID,
string(entity.EntityID),
samlApp.Metadata,
samlApp.MetadataURL)
if err != nil {
return nil, err
}
if !hasChanged {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-1m88i", "Errors.NoChangesFound")
}
pushedEvents, err := c.eventstore.Push(ctx, changedEvent)
if err != nil {
return nil, err
}
err = AppendAndReduce(existingSAML, pushedEvents...)
if err != nil {
return nil, err
}
return samlWriteModelToSAMLConfig(existingSAML), nil
}
func (c *Commands) getSAMLAppWriteModel(ctx context.Context, projectID, appID, resourceOwner string) (*SAMLApplicationWriteModel, error) {
appWriteModel := NewSAMLApplicationWriteModelWithAppID(projectID, appID, resourceOwner)
err := c.eventstore.FilterToQueryReducer(ctx, appWriteModel)
if err != nil {
return nil, err
}
return appWriteModel, nil
}

View File

@ -0,0 +1,268 @@
package command
import (
"context"
"reflect"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/project"
)
type SAMLApplicationWriteModel struct {
eventstore.WriteModel
AppID string
AppName string
EntityID string
Metadata []byte
MetadataURL string
State domain.AppState
saml bool
}
func NewSAMLApplicationWriteModelWithAppID(projectID, appID, resourceOwner string) *SAMLApplicationWriteModel {
return &SAMLApplicationWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: projectID,
ResourceOwner: resourceOwner,
},
AppID: appID,
}
}
func NewSAMLApplicationWriteModel(projectID, resourceOwner string) *SAMLApplicationWriteModel {
return &SAMLApplicationWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: projectID,
ResourceOwner: resourceOwner,
},
}
}
func (wm *SAMLApplicationWriteModel) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *project.ApplicationAddedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationChangedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationDeactivatedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationReactivatedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationRemovedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.SAMLConfigAddedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.SAMLConfigChangedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ProjectRemovedEvent:
wm.WriteModel.AppendEvents(e)
}
}
}
func (wm *SAMLApplicationWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *project.ApplicationAddedEvent:
wm.AppName = e.Name
wm.State = domain.AppStateActive
case *project.ApplicationChangedEvent:
wm.AppName = e.Name
case *project.ApplicationDeactivatedEvent:
if wm.State == domain.AppStateRemoved {
continue
}
wm.State = domain.AppStateInactive
case *project.ApplicationReactivatedEvent:
if wm.State == domain.AppStateRemoved {
continue
}
wm.State = domain.AppStateActive
case *project.ApplicationRemovedEvent:
wm.State = domain.AppStateRemoved
case *project.SAMLConfigAddedEvent:
wm.appendAddSAMLEvent(e)
case *project.SAMLConfigChangedEvent:
wm.appendChangeSAMLEvent(e)
case *project.ProjectRemovedEvent:
wm.State = domain.AppStateRemoved
}
}
return wm.WriteModel.Reduce()
}
func (wm *SAMLApplicationWriteModel) appendAddSAMLEvent(e *project.SAMLConfigAddedEvent) {
wm.saml = true
wm.Metadata = e.Metadata
wm.MetadataURL = e.MetadataURL
wm.EntityID = e.EntityID
}
func (wm *SAMLApplicationWriteModel) appendChangeSAMLEvent(e *project.SAMLConfigChangedEvent) {
wm.saml = true
if e.Metadata != nil {
wm.Metadata = e.Metadata
}
if e.MetadataURL != nil {
wm.MetadataURL = *e.MetadataURL
}
if e.EntityID != "" {
wm.EntityID = e.EntityID
}
}
func (wm *SAMLApplicationWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(project.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
project.ApplicationAddedType,
project.ApplicationChangedType,
project.ApplicationDeactivatedType,
project.ApplicationReactivatedType,
project.ApplicationRemovedType,
project.SAMLConfigAddedType,
project.SAMLConfigChangedType,
project.ProjectRemovedType).
Builder()
}
func (wm *SAMLApplicationWriteModel) NewChangedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
appID string,
entityID string,
metadata []byte,
metadataURL string,
) (*project.SAMLConfigChangedEvent, bool, error) {
changes := make([]project.SAMLConfigChanges, 0)
var err error
if !reflect.DeepEqual(wm.Metadata, metadata) {
changes = append(changes, project.ChangeMetadata(metadata))
}
if wm.MetadataURL != metadataURL {
changes = append(changes, project.ChangeMetadataURL(metadataURL))
}
if wm.EntityID != entityID {
changes = append(changes, project.ChangeEntityID(entityID))
}
if len(changes) == 0 {
return nil, false, nil
}
changeEvent, err := project.NewSAMLConfigChangedEvent(ctx, aggregate, appID, wm.EntityID, changes)
if err != nil {
return nil, false, err
}
return changeEvent, true, nil
}
func (wm *SAMLApplicationWriteModel) IsSAML() bool {
return wm.saml
}
type AppIDToEntityID struct {
AppID string
EntityID string
}
type SAMLEntityIDsWriteModel struct {
eventstore.WriteModel
EntityIDs []*AppIDToEntityID
}
func NewSAMLEntityIDsWriteModel(projectID, resourceOwner string) *SAMLEntityIDsWriteModel {
return &SAMLEntityIDsWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: projectID,
ResourceOwner: resourceOwner,
},
EntityIDs: []*AppIDToEntityID{},
}
}
func (wm *SAMLEntityIDsWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(project.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
project.ApplicationRemovedType,
project.SAMLConfigAddedType,
project.SAMLConfigChangedType).
Builder()
}
func (wm *SAMLEntityIDsWriteModel) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *project.ApplicationRemovedEvent:
wm.WriteModel.AppendEvents(e)
case *project.SAMLConfigAddedEvent:
wm.WriteModel.AppendEvents(e)
case *project.SAMLConfigChangedEvent:
if e.EntityID != "" {
wm.WriteModel.AppendEvents(e)
}
}
}
}
func (wm *SAMLEntityIDsWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *project.ApplicationRemovedEvent:
removeAppIDFromEntityIDs(wm.EntityIDs, e.AppID)
case *project.SAMLConfigAddedEvent:
wm.EntityIDs = append(wm.EntityIDs, &AppIDToEntityID{AppID: e.AppID, EntityID: e.EntityID})
case *project.SAMLConfigChangedEvent:
for i := range wm.EntityIDs {
item := wm.EntityIDs[i]
if e.AppID == item.AppID && e.EntityID != "" {
item.EntityID = e.EntityID
}
}
}
}
return wm.WriteModel.Reduce()
}
func removeAppIDFromEntityIDs(items []*AppIDToEntityID, appID string) []*AppIDToEntityID {
for i := len(items) - 1; i >= 0; i-- {
if items[i].AppID == appID {
items[i] = items[len(items)-1]
items[len(items)-1] = nil
items = items[:len(items)-1]
}
}
return items
}

View File

@ -0,0 +1,776 @@
package command
import (
"bytes"
"context"
"io/ioutil"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/id"
id_mock "github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/project"
)
var testMetadata = []byte(`<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
validUntil="2022-08-26T14:08:16Z"
cacheDuration="PT604800S"
entityID="https://test.com/saml/metadata">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://test.com/saml/acs"
index="1" />
</md:SPSSODescriptor>
</md:EntityDescriptor>
`)
var testMetadataChangedEntityID = []byte(`<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
validUntil="2022-08-26T14:08:16Z"
cacheDuration="PT604800S"
entityID="https://test2.com/saml/metadata">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://test.com/saml/acs"
index="1" />
</md:SPSSODescriptor>
</md:EntityDescriptor>
`)
func TestCommandSide_AddSAMLApplication(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
httpClient *http.Client
}
type args struct {
ctx context.Context
samlApp *domain.SAMLApp
resourceOwner string
}
type res struct {
want *domain.SAMLApp
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "no aggregate id, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{},
resourceOwner: "org1",
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "project not existing, not found error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppID: "app1",
AppName: "app",
},
resourceOwner: "org1",
},
res: res{
err: errors.IsPreconditionFailed,
},
},
{
name: "invalid app, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppID: "app1",
AppName: "",
},
resourceOwner: "org1",
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "create saml app, metadata not parsable",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppName: "app",
EntityID: "https://test.com/saml/metadata",
Metadata: []byte("test metadata"),
MetadataURL: "",
},
resourceOwner: "org1",
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "create saml app, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test.com/saml/metadata",
testMetadata,
"",
),
),
},
uniqueConstraintsFromEventConstraint(project.NewAddApplicationUniqueConstraint("app", "project1")),
uniqueConstraintsFromEventConstraint(project.NewAddSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata")),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1"),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppName: "app",
EntityID: "https://test.com/saml/metadata",
Metadata: testMetadata,
MetadataURL: "",
},
resourceOwner: "org1",
},
res: res{
want: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app",
EntityID: "https://test.com/saml/metadata",
Metadata: testMetadata,
MetadataURL: "",
State: domain.AppStateActive,
},
},
},
{
name: "create saml app metadataURL, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test.com/saml/metadata",
testMetadata,
"http://localhost:8080/saml/metadata",
),
),
},
uniqueConstraintsFromEventConstraint(project.NewAddApplicationUniqueConstraint("app", "project1")),
uniqueConstraintsFromEventConstraint(project.NewAddSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata")),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1"),
httpClient: newTestClient(200, testMetadata),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppName: "app",
EntityID: "https://test.com/saml/metadata",
Metadata: nil,
MetadataURL: "http://localhost:8080/saml/metadata",
},
resourceOwner: "org1",
},
res: res{
want: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app",
EntityID: "https://test.com/saml/metadata",
Metadata: testMetadata,
MetadataURL: "http://localhost:8080/saml/metadata",
State: domain.AppStateActive,
},
},
},
{
name: "create saml app metadataURL, http error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingUnspecified),
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t),
httpClient: newTestClient(http.StatusNotFound, nil),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppName: "app",
EntityID: "https://test.com/saml/metadata",
Metadata: nil,
MetadataURL: "http://localhost:8080/saml/metadata",
},
resourceOwner: "org1",
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
httpClient: tt.fields.httpClient,
}
got, err := r.AddSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
}
})
}
}
func TestCommandSide_ChangeSAMLApplication(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
httpClient *http.Client
}
type args struct {
ctx context.Context
samlApp *domain.SAMLApp
resourceOwner string
}
type res struct {
want *domain.SAMLApp
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "invalid app, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppID: "app1",
},
resourceOwner: "org1",
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "missing appid, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppID: "",
Metadata: []byte("just not empty"),
},
resourceOwner: "org1",
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "missing aggregateid, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "",
},
AppID: "appid",
Metadata: []byte("just not empty"),
},
resourceOwner: "org1",
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "app not existing, not found error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
},
AppID: "app1",
Metadata: []byte("just not empty"),
},
resourceOwner: "org1",
},
res: res{
err: errors.IsNotFound,
},
},
{
name: "no changes, precondition error, metadataURL",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test.com/saml/metadata",
testMetadata,
"http://localhost:8080/saml/metadata",
),
),
),
),
httpClient: newTestClient(http.StatusOK, testMetadata),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppName: "app",
AppID: "app1",
EntityID: "https://test.com/saml/metadata",
Metadata: nil,
MetadataURL: "http://localhost:8080/saml/metadata",
},
resourceOwner: "org1",
},
res: res{
err: errors.IsPreconditionFailed,
},
},
{
name: "no changes, precondition error, metadata",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test.com/saml/metadata",
testMetadata,
"",
),
),
),
),
httpClient: nil,
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppName: "app",
AppID: "app1",
EntityID: "https://test.com/saml/metadata",
Metadata: testMetadata,
MetadataURL: "",
},
resourceOwner: "org1",
},
res: res{
err: errors.IsPreconditionFailed,
},
},
{
name: "change saml app, ok, metadataURL",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test.com/saml/metadata",
testMetadata,
"http://localhost:8080/saml/metadata",
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newSAMLAppChangedEventMetadataURL(context.Background(),
"app1",
"project1",
"org1",
"https://test.com/saml/metadata",
"https://test2.com/saml/metadata",
testMetadataChangedEntityID,
),
),
},
uniqueConstraintsFromEventConstraint(project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata")),
uniqueConstraintsFromEventConstraint(project.NewAddSAMLConfigEntityIDUniqueConstraint("https://test2.com/saml/metadata")),
),
),
httpClient: newTestClient(http.StatusOK, testMetadataChangedEntityID),
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app",
EntityID: "https://test2.com/saml/metadata",
Metadata: nil,
MetadataURL: "http://localhost:8080/saml/metadata",
},
resourceOwner: "org1",
},
res: res{
want: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app",
EntityID: "https://test2.com/saml/metadata",
Metadata: testMetadataChangedEntityID,
MetadataURL: "http://localhost:8080/saml/metadata",
State: domain.AppStateActive,
},
},
},
{
name: "change saml app, ok, metadata",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
),
),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test.com/saml/metadata",
testMetadata,
"",
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newSAMLAppChangedEventMetadata(context.Background(),
"app1",
"project1",
"org1",
"https://test.com/saml/metadata",
"https://test2.com/saml/metadata",
testMetadataChangedEntityID,
),
),
},
uniqueConstraintsFromEventConstraint(project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata")),
uniqueConstraintsFromEventConstraint(project.NewAddSAMLConfigEntityIDUniqueConstraint("https://test2.com/saml/metadata")),
),
),
httpClient: nil,
},
args: args{
ctx: context.Background(),
samlApp: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app",
EntityID: "https://test2.com/saml/metadata",
Metadata: testMetadataChangedEntityID,
MetadataURL: "",
},
resourceOwner: "org1",
},
res: res{
want: &domain.SAMLApp{
ObjectRoot: models.ObjectRoot{
AggregateID: "project1",
ResourceOwner: "org1",
},
AppID: "app1",
AppName: "app",
EntityID: "https://test2.com/saml/metadata",
Metadata: testMetadataChangedEntityID,
MetadataURL: "",
State: domain.AppStateActive,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
httpClient: tt.fields.httpClient,
}
got, err := r.ChangeSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
}
})
}
}
func newSAMLAppChangedEventMetadata(ctx context.Context, appID, projectID, resourceOwner, oldEntityID, entityID string, metadata []byte) *project.SAMLConfigChangedEvent {
changes := []project.SAMLConfigChanges{
project.ChangeEntityID(entityID),
project.ChangeMetadata(metadata),
}
event, _ := project.NewSAMLConfigChangedEvent(ctx,
&project.NewAggregate(projectID, resourceOwner).Aggregate,
appID,
oldEntityID,
changes,
)
return event
}
func newSAMLAppChangedEventMetadataURL(ctx context.Context, appID, projectID, resourceOwner, oldEntityID, entityID string, metadata []byte) *project.SAMLConfigChangedEvent {
changes := []project.SAMLConfigChanges{
project.ChangeEntityID(entityID),
project.ChangeMetadata(metadata),
}
event, _ := project.NewSAMLConfigChangedEvent(ctx,
&project.NewAggregate(projectID, resourceOwner).Aggregate,
appID,
oldEntityID,
changes,
)
return event
}
type roundTripperFunc func(*http.Request) *http.Response
// RoundTrip implements the http.RoundTripper interface.
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req), nil
}
// NewTestClient returns *http.Client with Transport replaced to avoid making real calls
func newTestClient(httpStatus int, metadata []byte) *http.Client {
fn := roundTripperFunc(func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: httpStatus,
Body: ioutil.NopCloser(bytes.NewBuffer(metadata)),
Header: make(http.Header), //must be non-nil value
}
})
return &http.Client{
Transport: fn,
}
}

View File

@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
@ -580,6 +581,58 @@ func TestCommandSide_RemoveApplication(t *testing.T) {
err: caos_errs.IsNotFound,
},
},
{
name: "app remove, entityID, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
),
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
eventFromEventPusher(project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"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>"),
"",
)),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(project.NewApplicationRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
"https://test.com/saml/metadata",
)),
}, /**/
uniqueConstraintsFromEventConstraint(project.NewRemoveApplicationUniqueConstraint("app", "project1")),
uniqueConstraintsFromEventConstraint(project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata")),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
appID: "app1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "app remove, ok",
fields: fields{
@ -592,12 +645,15 @@ func TestCommandSide_RemoveApplication(t *testing.T) {
"app",
)),
),
// app is not saml, or no saml config available
expectFilter(),
expectPush(
[]*repository.Event{
eventFromEventPusher(project.NewApplicationRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
"",
)),
},
uniqueConstraintsFromEventConstraint(project.NewRemoveApplicationUniqueConstraint("app", "project1")),

View File

@ -57,6 +57,18 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O
}
}
func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.SAMLApp {
return &domain.SAMLApp{
ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel),
AppID: writeModel.AppID,
AppName: writeModel.AppName,
State: writeModel.State,
Metadata: writeModel.Metadata,
MetadataURL: writeModel.MetadataURL,
EntityID: writeModel.EntityID,
}
}
func apiWriteModelToAPIConfig(writeModel *APIApplicationWriteModel) *domain.APIApp {
return &domain.APIApp{
ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel),

View File

@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
@ -50,6 +51,7 @@ func TestCommandSide_AddProjectRole(t *testing.T) {
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"projectname1",
nil,
),
),
),
@ -253,6 +255,7 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) {
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"projectname1",
nil,
),
),
),
@ -503,6 +506,7 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) {
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"projectname1",
nil,
),
),
),

View File

@ -269,7 +269,8 @@ func TestCommandSide_ChangeProject(t *testing.T) {
eventFromEventPusher(
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project"),
"project",
nil),
),
),
),
@ -542,7 +543,8 @@ func TestCommandSide_DeactivateProject(t *testing.T) {
eventFromEventPusher(
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project"),
"project",
nil),
),
),
),
@ -721,7 +723,8 @@ func TestCommandSide_ReactivateProject(t *testing.T) {
eventFromEventPusher(
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project"),
"project",
nil),
),
),
),
@ -900,7 +903,8 @@ func TestCommandSide_RemoveProject(t *testing.T) {
eventFromEventPusher(
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project"),
"project",
nil),
),
),
),
@ -915,7 +919,7 @@ func TestCommandSide_RemoveProject(t *testing.T) {
},
},
{
name: "project remove, ok",
name: "project remove, without entityConstraints, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
@ -927,12 +931,15 @@ func TestCommandSide_RemoveProject(t *testing.T) {
domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy),
),
),
// no saml application events
expectFilter(),
expectPush(
[]*repository.Event{
eventFromEventPusher(
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project"),
"project",
nil),
),
},
uniqueConstraintsFromEventConstraint(project.NewRemoveProjectNameUniqueConstraint("project", "org1")),
@ -950,6 +957,150 @@ func TestCommandSide_RemoveProject(t *testing.T) {
},
},
},
{
name: "project remove, with entityConstraints, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy),
),
),
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"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>"),
"http://localhost:8080/saml/metadata",
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project",
[]*eventstore.EventUniqueConstraint{
project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata"),
}),
),
},
uniqueConstraintsFromEventConstraint(project.NewRemoveProjectNameUniqueConstraint("project", "org1")),
uniqueConstraintsFromEventConstraint(project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata")),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "project remove, with multiple entityConstraints, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
project.NewProjectAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy),
),
),
expectFilter(
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"app",
)),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app1",
"https://test1.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>"),
"",
),
),
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app2",
"app",
)),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app2",
"https://test2.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>"),
"",
),
),
eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app3",
"app",
)),
eventFromEventPusher(
project.NewSAMLConfigAddedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"app3",
"https://test3.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>"),
"",
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project",
[]*eventstore.EventUniqueConstraint{
project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test1.com/saml/metadata"),
project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test2.com/saml/metadata"),
project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test3.com/saml/metadata"),
}),
),
},
uniqueConstraintsFromEventConstraint(project.NewRemoveProjectNameUniqueConstraint("project", "org1")),
uniqueConstraintsFromEventConstraint(project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test1.com/saml/metadata")),
uniqueConstraintsFromEventConstraint(project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test2.com/saml/metadata")),
uniqueConstraintsFromEventConstraint(project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test3.com/saml/metadata")),
),
),
},
args: args{
ctx: context.Background(),
projectID: "project1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -4,6 +4,7 @@ import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/idpconfig"
@ -92,6 +93,10 @@ func (rm *UniqueConstraintReadModel) Reduce() error {
rm.addUniqueConstraint(e.Aggregate().ID, e.AppID, project.NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID))
case *project.ApplicationChangedEvent:
rm.changeUniqueConstraint(e.Aggregate().ID, e.AppID, project.NewAddApplicationUniqueConstraint(e.Name, e.Aggregate().ID))
case *project.SAMLConfigAddedEvent:
rm.addUniqueConstraint(e.Aggregate().ID, e.AppID, project.NewAddSAMLConfigEntityIDUniqueConstraint(e.EntityID))
case *project.SAMLConfigChangedEvent:
rm.addUniqueConstraint(e.Aggregate().ID, e.AppID, project.NewRemoveSAMLConfigEntityIDUniqueConstraint(e.EntityID))
case *project.ApplicationRemovedEvent:
rm.removeUniqueConstraint(e.Aggregate().ID, e.AppID, project.UniqueAppNameType)
case *project.GrantAddedEvent:

View File

@ -133,6 +133,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) {
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"projectname1",
nil,
),
),
),
@ -819,6 +820,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) {
project.NewProjectRemovedEvent(context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"projectname1",
nil,
),
),
),

View File

@ -40,4 +40,6 @@ type KeyConfig struct {
Size int
PrivateKeyLifetime time.Duration
PublicKeyLifetime time.Duration
CertificateSize int
CertificateLifetime time.Duration
}

View File

@ -1,11 +1,16 @@
package crypto
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"time"
)
func GenerateKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) {
@ -24,6 +29,104 @@ func GenerateEncryptedKeyPair(bits int, alg EncryptionAlgorithm) (*CryptoValue,
return EncryptKeys(privateKey, publicKey, alg)
}
type CertificateInformations struct {
SerialNumber *big.Int
Organisation []string
CommonName string
NotBefore time.Time
NotAfter time.Time
KeyUsage x509.KeyUsage
ExtKeyUsage []x509.ExtKeyUsage
}
func GenerateEncryptedKeyPairWithCACertificate(bits int, keyAlg, certAlg EncryptionAlgorithm, informations *CertificateInformations) (*CryptoValue, *CryptoValue, *CryptoValue, error) {
privateKey, publicKey, cert, err := GenerateCACertificate(bits, informations)
if err != nil {
return nil, nil, nil, err
}
encryptPriv, encryptPub, encryptCaCert, err := EncryptKeysAndCert(privateKey, publicKey, cert, keyAlg, certAlg)
if err != nil {
return nil, nil, nil, err
}
return encryptPriv, encryptPub, encryptCaCert, nil
}
func GenerateEncryptedKeyPairWithCertificate(bits int, keyAlg, certAlg EncryptionAlgorithm, caPrivateKey *rsa.PrivateKey, caCertificate []byte, informations *CertificateInformations) (*CryptoValue, *CryptoValue, *CryptoValue, error) {
privateKey, publicKey, cert, err := GenerateCertificate(bits, caPrivateKey, caCertificate, informations)
if err != nil {
return nil, nil, nil, err
}
encryptPriv, encryptPub, encryptCaCert, err := EncryptKeysAndCert(privateKey, publicKey, cert, keyAlg, certAlg)
if err != nil {
return nil, nil, nil, err
}
return encryptPriv, encryptPub, encryptCaCert, nil
}
func GenerateCACertificate(bits int, informations *CertificateInformations) (*rsa.PrivateKey, *rsa.PublicKey, []byte, error) {
return generateCertificate(bits, nil, nil, informations)
}
func GenerateCertificate(bits int, caPrivateKey *rsa.PrivateKey, ca []byte, informations *CertificateInformations) (*rsa.PrivateKey, *rsa.PublicKey, []byte, error) {
return generateCertificate(bits, caPrivateKey, ca, informations)
}
func generateCertificate(bits int, caPrivateKey *rsa.PrivateKey, ca []byte, informations *CertificateInformations) (*rsa.PrivateKey, *rsa.PublicKey, []byte, error) {
notBefore := time.Now()
if !informations.NotBefore.IsZero() {
notBefore = informations.NotBefore
}
cert := &x509.Certificate{
SerialNumber: informations.SerialNumber,
Subject: pkix.Name{
CommonName: informations.CommonName,
Organization: informations.Organisation,
},
NotBefore: notBefore,
NotAfter: informations.NotAfter,
KeyUsage: informations.KeyUsage,
ExtKeyUsage: informations.ExtKeyUsage,
}
certPrivKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, nil, nil, err
}
certBytes := make([]byte, 0)
if ca == nil {
cert.IsCA = true
cert.BasicConstraintsValid = true
certBytes, err = x509.CreateCertificate(rand.Reader, cert, cert, &certPrivKey.PublicKey, certPrivKey)
if err != nil {
return nil, nil, nil, err
}
} else {
caCert, err := x509.ParseCertificate(ca)
if err != nil {
return nil, nil, nil, err
}
certBytes, err = x509.CreateCertificate(rand.Reader, cert, caCert, &certPrivKey.PublicKey, caPrivateKey)
if err != nil {
return nil, nil, nil, err
}
}
x509Cert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, nil, nil, err
}
certPem, err := CertificateToBytes(x509Cert)
if err != nil {
return nil, nil, nil, err
}
return certPrivKey, &certPrivKey.PublicKey, certPem, nil
}
func PrivateKeyToBytes(priv *rsa.PrivateKey) []byte {
return pem.EncodeToMemory(
&pem.Block{
@ -101,3 +204,34 @@ func EncryptKeys(privateKey *rsa.PrivateKey, publicKey *rsa.PublicKey, alg Encry
}
return encryptedPrivateKey, encryptedPublicKey, nil
}
func CertificateToBytes(cert *x509.Certificate) ([]byte, error) {
certPem := new(bytes.Buffer)
if err := pem.Encode(certPem, &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}); err != nil {
return nil, err
}
return certPem.Bytes(), nil
}
func BytesToCertificate(data []byte) ([]byte, error) {
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("failed to decode PEM block containing public key")
}
return block.Bytes, nil
}
func EncryptKeysAndCert(privateKey *rsa.PrivateKey, publicKey *rsa.PublicKey, cert []byte, keyAlg, certAlg EncryptionAlgorithm) (*CryptoValue, *CryptoValue, *CryptoValue, error) {
encryptedPrivateKey, encryptedPublicKey, err := EncryptKeys(privateKey, publicKey, keyAlg)
if err != nil {
return nil, nil, nil, err
}
encryptedCertificate, err := Encrypt(cert, certAlg)
if err != nil {
return nil, nil, nil, err
}
return encryptedPrivateKey, encryptedPublicKey, encryptedCertificate, nil
}

View File

@ -0,0 +1,40 @@
package domain
import (
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
)
type SAMLApp struct {
models.ObjectRoot
AppID string
AppName string
EntityID string
Metadata []byte
MetadataURL string
State AppState
}
func (a *SAMLApp) GetApplicationName() string {
return a.AppName
}
func (a *SAMLApp) GetState() AppState {
return a.State
}
func (a *SAMLApp) GetMetadata() []byte {
return a.Metadata
}
func (a *SAMLApp) GetMetadataURL() string {
return a.MetadataURL
}
func (a *SAMLApp) IsValid() bool {
if a.MetadataURL == "" && a.Metadata == nil {
return false
}
return true
}

Some files were not shown because too many files have changed in this diff Show More