diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7cf07e9d..30f9db44a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: with: node_version: '18' buf_version: 'latest' - go_version: '1.20' + go_version: '1.21' console: uses: ./.github/workflows/console.yml @@ -35,7 +35,7 @@ jobs: needs: [core, console, version] uses: ./.github/workflows/compile.yml with: - go_version: '1.20' + go_version: '1.21' core_cache_key: ${{ needs.core.outputs.cache_key }} console_cache_key: ${{ needs.console.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} @@ -46,7 +46,7 @@ jobs: needs: core uses: ./.github/workflows/core-unit-test.yml with: - go_version: '1.20' + go_version: '1.21' core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} @@ -54,7 +54,7 @@ jobs: needs: core uses: ./.github/workflows/core-integration-test.yml with: - go_version: '1.20' + go_version: '1.21' core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} @@ -62,7 +62,7 @@ jobs: needs: [core, console] uses: ./.github/workflows/lint.yml with: - go_version: '1.20' + go_version: '1.21' node_version: '18' buf_version: 'latest' go_lint_version: 'v1.53.2' diff --git a/.golangci.yaml b/.golangci.yaml index 7768c32d6..bc1472119 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -8,7 +8,7 @@ issues: run: concurrency: 4 timeout: 10m - go: '1.19' + go: '1.21' skip-dirs: - .artifacts - .backups diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b04c7d36..128eb61a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,7 +135,7 @@ ZITADEL uses [golangci-lint](https://golangci-lint.run) for code quality checks. The commands in this section are tested against the following software versions: - [Docker version 20.10.17](https://docs.docker.com/engine/install/) -- [Go version 1.20](https://go.dev/doc/install) +- [Go version 1.21](https://go.dev/doc/install) - [Delve 1.9.1](https://github.com/go-delve/delve/tree/v1.9.1/Documentation/installation) Make some changes to the source code, then run the database locally. diff --git a/cmd/start/start.go b/cmd/start/start.go index 245928d91..f8d69dea5 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -380,7 +380,7 @@ func startAPIs( apis.RegisterHandlerOnPrefix(idp.HandlerPrefix, idp.NewHandler(commands, queries, keys.IDPConfig, config.ExternalSecure, instanceInterceptor.Handler)) - userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources) + userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources, login.EndpointExternalLoginCallbackFormPost) if err != nil { return err } diff --git a/console/angular.json b/console/angular.json index 076f905fe..1301e6686 100644 --- a/console/angular.json +++ b/console/angular.json @@ -56,8 +56,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "6mb", - "maximumError": "7mb" + "maximumWarning": "8mb", + "maximumError": "9mb" }, { "type": "anyComponentStyle", diff --git a/console/src/app/modules/idp-table/idp-table.component.html b/console/src/app/modules/idp-table/idp-table.component.html index 2990f196d..72801d58a 100644 --- a/console/src/app/modules/idp-table/idp-table.component.html +++ b/console/src/app/modules/idp-table/idp-table.component.html @@ -80,6 +80,11 @@ Active Directory / LDAP +
+ + + Apple +
coming soon
diff --git a/console/src/app/modules/idp-table/idp-table.component.scss b/console/src/app/modules/idp-table/idp-table.component.scss index 690c0a1f2..29cb7485a 100644 --- a/console/src/app/modules/idp-table/idp-table.component.scss +++ b/console/src/app/modules/idp-table/idp-table.component.scss @@ -21,6 +21,10 @@ width: 28px; flex-shrink: 0; + &.apple { + margin-bottom: 4px; + } + &.dark { display: if($is-dark-theme, block, none); } diff --git a/console/src/app/modules/idp-table/idp-table.component.ts b/console/src/app/modules/idp-table/idp-table.component.ts index 0735f0d52..b871d9329 100644 --- a/console/src/app/modules/idp-table/idp-table.component.ts +++ b/console/src/app/modules/idp-table/idp-table.component.ts @@ -261,6 +261,8 @@ export class IdpTableComponent implements OnInit, OnDestroy { ]; case ProviderType.PROVIDER_TYPE_GITHUB: return [row.owner === IDPOwnerType.IDP_OWNER_TYPE_SYSTEM ? '/instance' : '/org', 'provider', 'github', row.id]; + case ProviderType.PROVIDER_TYPE_APPLE: + return [row.owner === IDPOwnerType.IDP_OWNER_TYPE_SYSTEM ? '/instance' : '/org', 'provider', 'apple', row.id]; } } } diff --git a/console/src/app/modules/policies/idp-settings/idp-settings.component.html b/console/src/app/modules/policies/idp-settings/idp-settings.component.html index 376161fa0..9125008c5 100644 --- a/console/src/app/modules/policies/idp-settings/idp-settings.component.html +++ b/console/src/app/modules/policies/idp-settings/idp-settings.component.html @@ -109,6 +109,23 @@ + + + +
+ Apple +
+
+ +
+
+ + +

{{ 'IDP.CREATE.APPLE.TITLE' | translate }}

+ +
+ +

+ {{ !provider ? ('IDP.CREATE.APPLE.DESCRIPTION' | translate) : ('IDP.DETAIL.DESCRIPTION' | translate) }} +

+ +
+
+ + {{ 'IDP.CLIENTID' | translate }} + + + + + {{ 'IDP.APPLE.TEAMID' | translate }} + + + + + {{ 'IDP.APPLE.KEYID' | translate }} + + + + {{ 'IDP.APPLE.UPDATEPRIVATEKEY' | translate }} + + + {{ 'IDP.APPLE.PRIVATEKEY' | translate }} +
+ + + + + + + + +
+
+ +
+

{{ 'IDP.OPTIONAL' | translate }}

+ +
+
+
+
+ + {{ 'IDP.SCOPESLIST' | translate }} + + + + +
+ + + + + {{ scope }} cancel + + + +
+ + + {{ 'IDP.NAME' | translate }} + + {{ 'IDP.NAMEHINT' | translate }} + + + +
+
+ +
+ +
+
+
+ diff --git a/console/src/app/modules/providers/provider-apple/provider-apple.component.spec.ts b/console/src/app/modules/providers/provider-apple/provider-apple.component.spec.ts new file mode 100644 index 000000000..62c5350f5 --- /dev/null +++ b/console/src/app/modules/providers/provider-apple/provider-apple.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { ProviderAppleComponent } from './provider-apple.component'; + +describe('ProviderGoogleComponent', () => { + let component: ProviderAppleComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ProviderAppleComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProviderAppleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/console/src/app/modules/providers/provider-apple/provider-apple.component.ts b/console/src/app/modules/providers/provider-apple/provider-apple.component.ts new file mode 100644 index 000000000..b86204309 --- /dev/null +++ b/console/src/app/modules/providers/provider-apple/provider-apple.component.ts @@ -0,0 +1,306 @@ +import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; +import { Location } from '@angular/common'; +import { Component, Injector, Type } from '@angular/core'; +import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; +import { MatLegacyChipInputEvent as MatChipInputEvent } from '@angular/material/legacy-chips'; +import { ActivatedRoute } from '@angular/router'; +import { take } from 'rxjs'; +import { + AddAppleProviderRequest as AdminAddAppleProviderRequest, + GetProviderByIDRequest as AdminGetProviderByIDRequest, + UpdateAppleProviderRequest as AdminUpdateAppleProviderRequest, +} from 'src/app/proto/generated/zitadel/admin_pb'; +import { Options, Provider } from 'src/app/proto/generated/zitadel/idp_pb'; +import { + AddAppleProviderRequest as MgmtAddAppleProviderRequest, + GetProviderByIDRequest as MgmtGetProviderByIDRequest, + UpdateAppleProviderRequest as MgmtUpdateAppleProviderRequest, +} from 'src/app/proto/generated/zitadel/management_pb'; +import { AdminService } from 'src/app/services/admin.service'; +import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; +import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; +import { ManagementService } from 'src/app/services/mgmt.service'; +import { ToastService } from 'src/app/services/toast.service'; +import { requiredValidator } from '../../form-field/validators/validators'; + +import { PolicyComponentServiceType } from '../../policies/policy-component-types.enum'; + +const MAX_ALLOWED_SIZE = 5 * 1024; + +@Component({ + selector: 'cnsl-provider-apple', + templateUrl: './provider-apple.component.html', +}) +export class ProviderAppleComponent { + public showOptional: boolean = false; + public options: Options = new Options().setIsCreationAllowed(true).setIsLinkingAllowed(true); + public id: string | null = ''; + public serviceType: PolicyComponentServiceType = PolicyComponentServiceType.MGMT; + private service!: ManagementService | AdminService; + + public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE]; + + public form!: FormGroup; + + public loading: boolean = false; + + public provider?: Provider.AsObject; + public updatePrivateKey: boolean = false; + + constructor( + private authService: GrpcAuthService, + private route: ActivatedRoute, + private toast: ToastService, + private injector: Injector, + private _location: Location, + private breadcrumbService: BreadcrumbService, + ) { + this.form = new FormGroup({ + name: new FormControl('', []), + clientId: new FormControl('', [requiredValidator]), + teamId: new FormControl('', [requiredValidator]), + keyId: new FormControl('', [requiredValidator]), + privateKey: new FormControl('', [requiredValidator]), + scopesList: new FormControl(['name', 'email'], []), + }); + + this.authService + .isAllowed( + this.serviceType === PolicyComponentServiceType.ADMIN + ? ['iam.idp.write'] + : this.serviceType === PolicyComponentServiceType.MGMT + ? ['org.idp.write'] + : [], + ) + .pipe(take(1)) + .subscribe((allowed) => { + if (allowed) { + this.form.enable(); + } else { + this.form.disable(); + } + }); + + this.route.data.pipe(take(1)).subscribe((data) => { + this.serviceType = data['serviceType']; + + switch (this.serviceType) { + case PolicyComponentServiceType.MGMT: + this.service = this.injector.get(ManagementService as Type); + + const bread: Breadcrumb = { + type: BreadcrumbType.ORG, + routerLink: ['/org'], + }; + + this.breadcrumbService.setBreadcrumb([bread]); + break; + case PolicyComponentServiceType.ADMIN: + this.service = this.injector.get(AdminService as Type); + + const iamBread = new Breadcrumb({ + type: BreadcrumbType.ORG, + name: 'Instance', + routerLink: ['/instance'], + }); + this.breadcrumbService.setBreadcrumb([iamBread]); + break; + } + + this.id = this.route.snapshot.paramMap.get('id'); + if (this.id) { + this.privateKey?.setValidators([]); + this.getData(this.id); + } + }); + } + + private getData(id: string): void { + const req = + this.serviceType === PolicyComponentServiceType.ADMIN + ? new AdminGetProviderByIDRequest() + : new MgmtGetProviderByIDRequest(); + req.setId(id); + this.service + .getProviderByID(req) + .then((resp) => { + this.provider = resp.idp; + this.loading = false; + if (this.provider?.config?.apple) { + this.form.patchValue(this.provider.config.apple); + this.name?.setValue(this.provider.name); + } + }) + .catch((error) => { + this.toast.showError(error); + this.loading = false; + }); + } + + public submitForm(): void { + this.provider ? this.updateAppleProvider() : this.addAppleProvider(); + } + + public addAppleProvider(): void { + const req = + this.serviceType === PolicyComponentServiceType.MGMT + ? new MgmtAddAppleProviderRequest() + : new AdminAddAppleProviderRequest(); + + req.setName(this.name?.value); + req.setClientId(this.clientId?.value); + req.setTeamId(this.teamId?.value); + req.setKeyId(this.keyId?.value); + req.setPrivateKey(this.privateKey?.value); + req.setScopesList(this.scopesList?.value); + req.setProviderOptions(this.options); + + this.loading = true; + this.service + .addAppleProvider(req) + .then((idp) => { + setTimeout(() => { + this.loading = false; + this.close(); + }, 2000); + }) + .catch((error) => { + this.toast.showError(error); + this.loading = false; + }); + } + + public updateAppleProvider(): void { + if (this.provider) { + if (this.serviceType === PolicyComponentServiceType.MGMT) { + const req = new MgmtUpdateAppleProviderRequest(); + req.setId(this.provider.id); + req.setName(this.name?.value); + req.setClientId(this.clientId?.value); + req.setTeamId(this.teamId?.value); + req.setKeyId(this.keyId?.value); + req.setScopesList(this.scopesList?.value); + req.setProviderOptions(this.options); + + if (this.updatePrivateKey) { + req.setPrivateKey(this.privateKey?.value); + } + + this.loading = true; + (this.service as ManagementService) + .updateAppleProvider(req) + .then((idp) => { + setTimeout(() => { + this.loading = false; + this.close(); + }, 2000); + }) + .catch((error) => { + this.toast.showError(error); + this.loading = false; + }); + } else if (PolicyComponentServiceType.ADMIN) { + const req = new AdminUpdateAppleProviderRequest(); + req.setId(this.provider.id); + req.setName(this.name?.value); + req.setClientId(this.clientId?.value); + req.setTeamId(this.teamId?.value); + req.setKeyId(this.keyId?.value); + req.setScopesList(this.scopesList?.value); + req.setProviderOptions(this.options); + + if (this.updatePrivateKey) { + req.setPrivateKey(this.privateKey?.value); + } + + this.loading = true; + (this.service as AdminService) + .updateAppleProvider(req) + .then((idp) => { + setTimeout(() => { + this.loading = false; + this.close(); + }, 2000); + }) + .catch((error) => { + this.loading = false; + this.toast.showError(error); + }); + } + } + } + + public close(): void { + this._location.back(); + } + + public onDropKey(filelist: FileList): void { + const file = filelist.item(0); + if (file) { + if (file.size > MAX_ALLOWED_SIZE) { + this.toast.showInfo('IDP.APPLE.KEYMAXSIZEEXCEEDED', true); + } else { + this.privateKey?.setValue(''); + const reader = new FileReader(); + reader.onload = ((aXML) => { + return (e) => { + const keyBase64 = e.target?.result; + if (keyBase64 && typeof keyBase64 === 'string') { + const cropped = keyBase64.replace('data:application/octet-stream;base64,', ''); + this.privateKey?.setValue(cropped); + } + }; + })(file); + reader.readAsDataURL(file); + } + } + } + + public addScope(event: MatChipInputEvent): void { + const input = event.chipInput?.inputElement; + const value = event.value.trim(); + + if (value !== '') { + if (this.scopesList?.value) { + this.scopesList.value.push(value); + if (input) { + input.value = ''; + } + } + } + } + + public removeScope(uri: string): void { + if (this.scopesList?.value) { + const index = this.scopesList.value.indexOf(uri); + + if (index !== undefined && index >= 0) { + this.scopesList.value.splice(index, 1); + } + } + } + + public get name(): AbstractControl | null { + return this.form.get('name'); + } + + public get clientId(): AbstractControl | null { + return this.form.get('clientId'); + } + + public get teamId(): AbstractControl | null { + return this.form.get('teamId'); + } + + public get keyId(): AbstractControl | null { + return this.form.get('keyId'); + } + + public get privateKey(): AbstractControl | null { + return this.form.get('privateKey'); + } + + public get scopesList(): AbstractControl | null { + return this.form.get('scopesList'); + } +} diff --git a/console/src/app/modules/providers/providers-routing.module.ts b/console/src/app/modules/providers/providers-routing.module.ts index 2e84b004b..224a620a9 100644 --- a/console/src/app/modules/providers/providers-routing.module.ts +++ b/console/src/app/modules/providers/providers-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { ProviderType } from 'src/app/proto/generated/zitadel/idp_pb'; +import { ProviderAppleComponent } from './provider-apple/provider-apple.component'; import { ProviderAzureADComponent } from './provider-azure-ad/provider-azure-ad.component'; import { ProviderGithubESComponent } from './provider-github-es/provider-github-es.component'; import { ProviderGithubComponent } from './provider-github/provider-github.component'; @@ -27,6 +28,7 @@ const typeMap = { [ProviderType.PROVIDER_TYPE_OAUTH]: { path: 'oauth', component: ProviderOAuthComponent }, [ProviderType.PROVIDER_TYPE_OIDC]: { path: 'oidc', component: ProviderOIDCComponent }, [ProviderType.PROVIDER_TYPE_LDAP]: { path: 'ldap', component: ProviderLDAPComponent }, + [ProviderType.PROVIDER_TYPE_APPLE]: { path: 'apple', component: ProviderAppleComponent }, }; const routes: Routes = Object.entries(typeMap).map(([key, value]) => { diff --git a/console/src/app/modules/providers/providers.module.ts b/console/src/app/modules/providers/providers.module.ts index b002ba41a..47e2679cf 100644 --- a/console/src/app/modules/providers/providers.module.ts +++ b/console/src/app/modules/providers/providers.module.ts @@ -17,6 +17,7 @@ import { InfoSectionModule } from '../info-section/info-section.module'; import { ProviderOptionsModule } from '../provider-options/provider-options.module'; import { StringListModule } from '../string-list/string-list.module'; import { LDAPAttributesComponent } from './ldap-attributes/ldap-attributes.component'; +import { ProviderAppleComponent } from './provider-apple/provider-apple.component'; import { ProviderAzureADComponent } from './provider-azure-ad/provider-azure-ad.component'; import { ProviderGithubESComponent } from './provider-github-es/provider-github-es.component'; import { ProviderGithubComponent } from './provider-github/provider-github.component'; @@ -43,6 +44,7 @@ import { ProvidersRoutingModule } from './providers-routing.module'; ProviderOIDCComponent, ProviderOAuthComponent, ProviderLDAPComponent, + ProviderAppleComponent, ], imports: [ ProvidersRoutingModule, diff --git a/console/src/app/modules/providers/providers.scss b/console/src/app/modules/providers/providers.scss index 4eb7702c6..8e96c3428 100644 --- a/console/src/app/modules/providers/providers.scss +++ b/console/src/app/modules/providers/providers.scss @@ -3,6 +3,7 @@ @mixin identity-provider-theme($theme) { $is-dark-theme: map-get($theme, is-dark); $background: map-get($theme, background); + $foreground: map-get($theme, foreground); .identity-provider-desc { font-size: 14px; @@ -19,6 +20,10 @@ margin-right: 1rem; flex-shrink: 0; + &.apple { + margin-bottom: 4px; + } + &.dark { display: if($is-dark-theme, block, none); } @@ -74,6 +79,58 @@ } } + .pk-label { + font-size: 12px; + margin-top: 0.5rem; + } + + .private-key-wrapper { + position: relative; + height: 70px; + width: 70px; + box-sizing: border-box; + border-radius: 0.5rem; + margin-top: 0.5rem; + border: 1px solid map-get($foreground, divider); + display: flex; + justify-content: center; + align-items: center; + + .dl-btn { + z-index: 2; + position: absolute; + right: 0; + top: 0; + cursor: pointer; + visibility: hidden; + transform: translateX(50%) translateY(-50%); + } + + img { + top: 0; + left: 0; + width: 70px; + height: 70px; + object-fit: contain; + object-position: center; + border-radius: 0.5rem; + } + + &.icon { + border-radius: 50%; + + img { + border-radius: 50%; + } + } + + &:hover { + .dl-btn { + visibility: visible; + } + } + } + .string-list-component-wrapper { max-width: 400px; } diff --git a/console/src/app/services/admin.service.ts b/console/src/app/services/admin.service.ts index 0b212f528..a4217e3ab 100644 --- a/console/src/app/services/admin.service.ts +++ b/console/src/app/services/admin.service.ts @@ -6,6 +6,8 @@ import { ActivateLabelPolicyResponse, ActivateSMSProviderRequest, ActivateSMSProviderResponse, + AddAppleProviderRequest, + AddAppleProviderResponse, AddAzureADProviderRequest, AddAzureADProviderResponse, AddCustomDomainPolicyRequest, @@ -214,6 +216,8 @@ import { SetSecurityPolicyResponse, SetUpOrgRequest, SetUpOrgResponse, + UpdateAppleProviderRequest, + UpdateAppleProviderResponse, UpdateAzureADProviderRequest, UpdateAzureADProviderResponse, UpdateCustomDomainPolicyRequest, @@ -1173,6 +1177,14 @@ export class AdminService { return this.grpcService.admin.updateGitHubEnterpriseServerProvider(req, null).then((resp) => resp.toObject()); } + public addAppleProvider(req: AddAppleProviderRequest): Promise { + return this.grpcService.admin.addAppleProvider(req, null).then((resp) => resp.toObject()); + } + + public updateAppleProvider(req: UpdateAppleProviderRequest): Promise { + return this.grpcService.admin.updateAppleProvider(req, null).then((resp) => resp.toObject()); + } + public deleteProvider(id: string): Promise { const req = new DeleteProviderRequest(); req.setId(id); diff --git a/console/src/app/services/mgmt.service.ts b/console/src/app/services/mgmt.service.ts index 28cbed554..c80e9e9b4 100644 --- a/console/src/app/services/mgmt.service.ts +++ b/console/src/app/services/mgmt.service.ts @@ -15,6 +15,8 @@ import { AddAPIAppResponse, AddAppKeyRequest, AddAppKeyResponse, + AddAppleProviderRequest, + AddAppleProviderResponse, AddAzureADProviderRequest, AddAzureADProviderResponse, AddCustomLabelPolicyRequest, @@ -441,6 +443,8 @@ import { UpdateActionResponse, UpdateAPIAppConfigRequest, UpdateAPIAppConfigResponse, + UpdateAppleProviderRequest, + UpdateAppleProviderResponse, UpdateAppRequest, UpdateAppResponse, UpdateAzureADProviderRequest, @@ -1041,6 +1045,14 @@ export class ManagementService { return this.grpcService.mgmt.updateGitHubEnterpriseServerProvider(req, null).then((resp) => resp.toObject()); } + public addAppleProvider(req: AddAppleProviderRequest): Promise { + return this.grpcService.mgmt.addAppleProvider(req, null).then((resp) => resp.toObject()); + } + + public updateAppleProvider(req: UpdateAppleProviderRequest): Promise { + return this.grpcService.mgmt.updateAppleProvider(req, null).then((resp) => resp.toObject()); + } + public deleteProvider(id: string): Promise { const req = new DeleteProviderRequest(); req.setId(id); diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 7bad50009..9c8daa3c3 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1706,6 +1706,10 @@ "LDAP": { "TITLE": "Active Directory / LDAP", "DESCRIPTION": "Enter the credentials for your LDAP Provider" + }, + "APPLE": { + "TITLE": "Sign in with Apple", + "DESCRIPTION": "Enter the credentials for your Apple Provider" } }, "DETAIL": { @@ -1819,6 +1823,14 @@ "JWTENDPOINT": "JWT Endpoint", "JWTKEYSENDPOINT": "JWT Keys Endpoint" }, + "APPLE": { + "TEAMID": "Team ID", + "KEYID": "Key ID", + "PRIVATEKEY": "Private Key", + "UPDATEPRIVATEKEY": "Update Private Key", + "UPLOADPRIVATEKEY": "Upload Private Key", + "KEYMAXSIZEEXCEEDED": "Maximum size of 5kB exceeded." + }, "TOAST": { "SAVED": "Successfully saved.", "REACTIVATED": "Idp reactivated.", diff --git a/console/src/assets/images/idp/apple-dark.svg b/console/src/assets/images/idp/apple-dark.svg new file mode 100755 index 000000000..0fed0b43e --- /dev/null +++ b/console/src/assets/images/idp/apple-dark.svg @@ -0,0 +1,10 @@ + + + + White Logo Square + Created with Sketch. + + + + + diff --git a/console/src/assets/images/idp/apple.svg b/console/src/assets/images/idp/apple.svg new file mode 100755 index 000000000..e2ac901df --- /dev/null +++ b/console/src/assets/images/idp/apple.svg @@ -0,0 +1,10 @@ + + + + Black Logo Square + Created with Sketch. + + + + + diff --git a/go.mod b/go.mod index 07620215d..db2e7fbf7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/zitadel/zitadel -go 1.20 +go 1.21 require ( cloud.google.com/go/storage v1.30.1 diff --git a/internal/api/grpc/admin/idp.go b/internal/api/grpc/admin/idp.go index 5d178b5b8..d5bc2cef9 100644 --- a/internal/api/grpc/admin/idp.go +++ b/internal/api/grpc/admin/idp.go @@ -405,6 +405,27 @@ func (s *Server) UpdateLDAPProvider(ctx context.Context, req *admin_pb.UpdateLDA }, nil } +func (s *Server) AddAppleProvider(ctx context.Context, req *admin_pb.AddAppleProviderRequest) (*admin_pb.AddAppleProviderResponse, error) { + id, details, err := s.command.AddInstanceAppleProvider(ctx, addAppleProviderToCommand(req)) + if err != nil { + return nil, err + } + return &admin_pb.AddAppleProviderResponse{ + Id: id, + Details: object_pb.DomainToAddDetailsPb(details), + }, nil +} + +func (s *Server) UpdateAppleProvider(ctx context.Context, req *admin_pb.UpdateAppleProviderRequest) (*admin_pb.UpdateAppleProviderResponse, error) { + details, err := s.command.UpdateInstanceAppleProvider(ctx, req.Id, updateAppleProviderToCommand(req)) + if err != nil { + return nil, err + } + return &admin_pb.UpdateAppleProviderResponse{ + Details: object_pb.DomainToChangeDetailsPb(details), + }, nil +} + func (s *Server) DeleteProvider(ctx context.Context, req *admin_pb.DeleteProviderRequest) (*admin_pb.DeleteProviderResponse, error) { details, err := s.command.DeleteInstanceProvider(ctx, req.Id) if err != nil { diff --git a/internal/api/grpc/admin/idp_converter.go b/internal/api/grpc/admin/idp_converter.go index c9dd3ae08..3ab176276 100644 --- a/internal/api/grpc/admin/idp_converter.go +++ b/internal/api/grpc/admin/idp_converter.go @@ -440,3 +440,27 @@ func updateLDAPProviderToCommand(req *admin_pb.UpdateLDAPProviderRequest) comman IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } + +func addAppleProviderToCommand(req *admin_pb.AddAppleProviderRequest) command.AppleProvider { + return command.AppleProvider{ + Name: req.Name, + ClientID: req.ClientId, + TeamID: req.TeamId, + KeyID: req.KeyId, + PrivateKey: req.PrivateKey, + Scopes: req.Scopes, + IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), + } +} + +func updateAppleProviderToCommand(req *admin_pb.UpdateAppleProviderRequest) command.AppleProvider { + return command.AppleProvider{ + Name: req.Name, + ClientID: req.ClientId, + TeamID: req.TeamId, + KeyID: req.KeyId, + PrivateKey: req.PrivateKey, + Scopes: req.Scopes, + IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), + } +} diff --git a/internal/api/grpc/idp/converter.go b/internal/api/grpc/idp/converter.go index c3980bd03..f3b0f5077 100644 --- a/internal/api/grpc/idp/converter.go +++ b/internal/api/grpc/idp/converter.go @@ -414,6 +414,8 @@ func providerTypeToPb(idpType domain.IDPType) idp_pb.ProviderType { return idp_pb.ProviderType_PROVIDER_TYPE_GITLAB_SELF_HOSTED case domain.IDPTypeGoogle: return idp_pb.ProviderType_PROVIDER_TYPE_GOOGLE + case domain.IDPTypeApple: + return idp_pb.ProviderType_PROVIDER_TYPE_APPLE case domain.IDPTypeUnspecified: return idp_pb.ProviderType_PROVIDER_TYPE_UNSPECIFIED default: @@ -470,6 +472,10 @@ func configToPb(config *query.IDPTemplate) *idp_pb.ProviderConfig { ldapConfigToPb(providerConfig, config.LDAPIDPTemplate) return providerConfig } + if config.AppleIDPTemplate != nil { + appleConfigToPb(providerConfig, config.AppleIDPTemplate) + return providerConfig + } return providerConfig } @@ -620,3 +626,14 @@ func ldapAttributesToPb(attributes idp.LDAPAttributes) *idp_pb.LDAPAttributes { ProfileAttribute: attributes.ProfileAttribute, } } + +func appleConfigToPb(providerConfig *idp_pb.ProviderConfig, template *query.AppleIDPTemplate) { + providerConfig.Config = &idp_pb.ProviderConfig_Apple{ + Apple: &idp_pb.AppleConfig{ + ClientId: template.ClientID, + TeamId: template.TeamID, + KeyId: template.KeyID, + Scopes: template.Scopes, + }, + } +} diff --git a/internal/api/grpc/management/idp.go b/internal/api/grpc/management/idp.go index 3803b41ec..7ed3c4551 100644 --- a/internal/api/grpc/management/idp.go +++ b/internal/api/grpc/management/idp.go @@ -397,6 +397,27 @@ func (s *Server) UpdateLDAPProvider(ctx context.Context, req *mgmt_pb.UpdateLDAP }, nil } +func (s *Server) AddAppleProvider(ctx context.Context, req *mgmt_pb.AddAppleProviderRequest) (*mgmt_pb.AddAppleProviderResponse, error) { + id, details, err := s.command.AddOrgAppleProvider(ctx, authz.GetCtxData(ctx).OrgID, addAppleProviderToCommand(req)) + if err != nil { + return nil, err + } + return &mgmt_pb.AddAppleProviderResponse{ + Id: id, + Details: object_pb.DomainToAddDetailsPb(details), + }, nil +} + +func (s *Server) UpdateAppleProvider(ctx context.Context, req *mgmt_pb.UpdateAppleProviderRequest) (*mgmt_pb.UpdateAppleProviderResponse, error) { + details, err := s.command.UpdateOrgAppleProvider(ctx, authz.GetCtxData(ctx).OrgID, req.Id, updateAppleProviderToCommand(req)) + if err != nil { + return nil, err + } + return &mgmt_pb.UpdateAppleProviderResponse{ + Details: object_pb.DomainToChangeDetailsPb(details), + }, nil +} + func (s *Server) DeleteProvider(ctx context.Context, req *mgmt_pb.DeleteProviderRequest) (*mgmt_pb.DeleteProviderResponse, error) { details, err := s.command.DeleteOrgProvider(ctx, authz.GetCtxData(ctx).OrgID, req.Id) if err != nil { diff --git a/internal/api/grpc/management/idp_converter.go b/internal/api/grpc/management/idp_converter.go index 0d70aca0d..7630be7ce 100644 --- a/internal/api/grpc/management/idp_converter.go +++ b/internal/api/grpc/management/idp_converter.go @@ -457,3 +457,27 @@ func updateLDAPProviderToCommand(req *mgmt_pb.UpdateLDAPProviderRequest) command IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), } } + +func addAppleProviderToCommand(req *mgmt_pb.AddAppleProviderRequest) command.AppleProvider { + return command.AppleProvider{ + Name: req.Name, + ClientID: req.ClientId, + TeamID: req.TeamId, + KeyID: req.KeyId, + PrivateKey: req.PrivateKey, + Scopes: req.Scopes, + IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), + } +} + +func updateAppleProviderToCommand(req *mgmt_pb.UpdateAppleProviderRequest) command.AppleProvider { + return command.AppleProvider{ + Name: req.Name, + ClientID: req.ClientId, + TeamID: req.TeamId, + KeyID: req.KeyId, + PrivateKey: req.PrivateKey, + Scopes: req.Scopes, + IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), + } +} diff --git a/internal/api/idp/idp.go b/internal/api/idp/idp.go index 99e709d14..486d47238 100644 --- a/internal/api/idp/idp.go +++ b/internal/api/idp/idp.go @@ -16,6 +16,7 @@ import ( z_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/form" "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/apple" "github.com/zitadel/zitadel/internal/idp/providers/azuread" "github.com/zitadel/zitadel/internal/idp/providers/github" "github.com/zitadel/zitadel/internal/idp/providers/gitlab" @@ -52,6 +53,9 @@ type externalIDPCallbackData struct { Code string `schema:"code"` Error string `schema:"error"` ErrorDescription string `schema:"error_description"` + + // Apple returns a user on first registration + User string `schema:"user"` } // CallbackURL generates the instance specific URL to the IDP callback handler @@ -115,7 +119,7 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { return } - idpUser, idpSession, err := h.fetchIDPUser(ctx, provider, data.Code) + idpUser, idpSession, err := h.fetchIDPUser(ctx, provider, data.Code, data.User) if err != nil { cmdErr := h.commands.FailIDPIntent(ctx, intent, err.Error()) logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") @@ -214,7 +218,7 @@ func redirectToFailureURL(w http.ResponseWriter, r *http.Request, i *command.IDP http.Redirect(w, r, i.FailureURL.String(), http.StatusFound) } -func (h *Handler) fetchIDPUser(ctx context.Context, identityProvider idp.Provider, code string) (user idp.User, idpTokens idp.Session, err error) { +func (h *Handler) fetchIDPUser(ctx context.Context, identityProvider idp.Provider, code string, appleUser string) (user idp.User, idpTokens idp.Session, err error) { var session idp.Session switch provider := identityProvider.(type) { case *oauth.Provider: @@ -229,6 +233,8 @@ func (h *Handler) fetchIDPUser(ctx context.Context, identityProvider idp.Provide session = &openid.Session{Provider: provider.Provider, Code: code} case *google.Provider: session = &openid.Session{Provider: provider.Provider, Code: code} + case *apple.Provider: + session = &apple.Session{Session: &openid.Session{Provider: provider.Provider, Code: code}, UserFormValue: appleUser} case *jwt.Provider, *ldap.Provider: return nil, nil, z_errs.ThrowInvalidArgument(nil, "IDP-52jmn", "Errors.ExternalIDP.IDPTypeNotImplemented") default: diff --git a/internal/api/ui/login/auth_request.go b/internal/api/ui/login/auth_request.go index 4478d129e..15a39b0d8 100644 --- a/internal/api/ui/login/auth_request.go +++ b/internal/api/ui/login/auth_request.go @@ -3,9 +3,8 @@ package login import ( "net/http" - "github.com/zitadel/zitadel/internal/domain" - http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/domain" ) const ( diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index f9bfb4023..475798e83 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/apple" "github.com/zitadel/zitadel/internal/idp/providers/azuread" "github.com/zitadel/zitadel/internal/idp/providers/github" "github.com/zitadel/zitadel/internal/idp/providers/gitlab" @@ -41,6 +42,9 @@ type externalIDPData struct { type externalIDPCallbackData struct { State string `schema:"state"` Code string `schema:"code"` + + // Apple returns a user on first registration + User string `schema:"user"` } type externalNotFoundOptionFormData struct { @@ -159,6 +163,8 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai provider, err = l.gitlabSelfHostedProvider(r.Context(), identityProvider) case domain.IDPTypeGoogle: provider, err = l.googleProvider(r.Context(), identityProvider) + case domain.IDPTypeApple: + provider, err = l.appleProvider(r.Context(), identityProvider) case domain.IDPTypeLDAP: provider, err = l.ldapProvider(r.Context(), identityProvider) case domain.IDPTypeUnspecified: @@ -180,6 +186,18 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai http.Redirect(w, r, session.GetAuthURL(), http.StatusFound) } +// handleExternalLoginCallbackForm handles the callback from a IDP with form_post. +// It will redirect to the "normal" callback endpoint with the form data as query parameter. +// This way cookies will be handled correctly (same site = lax). +func (l *Login) handleExternalLoginCallbackForm(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + l.renderLogin(w, r, nil, err) + return + } + http.Redirect(w, r, HandlerPrefix+EndpointExternalLoginCallback+"?"+r.Form.Encode(), 302) +} + // handleExternalLoginCallback handles the callback from a IDP // and tries to extract the user with the provided data func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Request) { @@ -259,6 +277,13 @@ func (l *Login) handleExternalLoginCallback(w http.ResponseWriter, r *http.Reque return } session = &openid.Session{Provider: provider.(*google.Provider).Provider, Code: data.Code} + case domain.IDPTypeApple: + provider, err = l.appleProvider(r.Context(), identityProvider) + if err != nil { + l.externalAuthFailed(w, r, authReq, nil, nil, err) + return + } + session = &apple.Session{Session: &openid.Session{Provider: provider.(*apple.Provider).Provider, Code: data.Code}, UserFormValue: data.User} case domain.IDPTypeJWT, domain.IDPTypeLDAP, domain.IDPTypeUnspecified: @@ -936,6 +961,21 @@ func (l *Login) gitlabSelfHostedProvider(ctx context.Context, identityProvider * ) } +func (l *Login) appleProvider(ctx context.Context, identityProvider *query.IDPTemplate) (*apple.Provider, error) { + privateKey, err := crypto.Decrypt(identityProvider.AppleIDPTemplate.PrivateKey, l.idpConfigAlg) + if err != nil { + return nil, err + } + return apple.New( + identityProvider.AppleIDPTemplate.ClientID, + identityProvider.AppleIDPTemplate.TeamID, + identityProvider.AppleIDPTemplate.KeyID, + l.baseURL(ctx)+EndpointExternalLoginCallbackFormPost, + privateKey, + identityProvider.AppleIDPTemplate.Scopes, + ) +} + func (l *Login) appendUserGrants(ctx context.Context, userGrants []*domain.UserGrant, resourceOwner string) error { if len(userGrants) == 0 { return nil @@ -971,6 +1011,8 @@ func tokens(session idp.Session) *oidc.Tokens[*oidc.IDTokenClaims] { return s.Tokens case *azuread.Session: return s.Tokens + case *apple.Session: + return s.Tokens } return nil } diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index 64a463f77..c98fbbff4 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -120,6 +120,12 @@ func createCSRFInterceptor(cookieName string, csrfCookieKey []byte, externalSecu handler.ServeHTTP(w, r) return } + // ignore form post callback + // it will redirect to the "normal" callback, where the cookie is set again + if r.URL.Path == EndpointExternalLoginCallbackFormPost && r.Method == http.MethodPost { + handler.ServeHTTP(w, r) + return + } csrf.Protect(csrfCookieKey, csrf.Secure(externalSecure), csrf.CookieName(http_utils.SetCookiePrefix(cookieName, "", path, externalSecure)), diff --git a/internal/api/ui/login/router.go b/internal/api/ui/login/router.go index 62ac4d9af..1bef0debd 100644 --- a/internal/api/ui/login/router.go +++ b/internal/api/ui/login/router.go @@ -7,44 +7,45 @@ import ( ) const ( - EndpointRoot = "/" - EndpointHealthz = "/healthz" - EndpointReadiness = "/ready" - EndpointLogin = "/login" - EndpointExternalLogin = "/login/externalidp" - EndpointExternalLoginCallback = "/login/externalidp/callback" - EndpointJWTAuthorize = "/login/jwt/authorize" - EndpointJWTCallback = "/login/jwt/callback" - EndpointLDAPLogin = "/login/ldap" - EndpointLDAPCallback = "/login/ldap/callback" - EndpointPasswordlessLogin = "/login/passwordless" - EndpointPasswordlessRegistration = "/login/passwordless/init" - EndpointPasswordlessPrompt = "/login/passwordless/prompt" - EndpointLoginName = "/loginname" - EndpointUserSelection = "/userselection" - EndpointChangeUsername = "/username/change" - EndpointPassword = "/password" - EndpointInitPassword = "/password/init" - EndpointChangePassword = "/password/change" - EndpointPasswordReset = "/password/reset" - EndpointInitUser = "/user/init" - EndpointMFAVerify = "/mfa/verify" - EndpointMFAPrompt = "/mfa/prompt" - EndpointMFAInitVerify = "/mfa/init/verify" - EndpointMFASMSInitVerify = "/mfa/init/sms/verify" - EndpointMFAOTPVerify = "/mfa/otp/verify" - EndpointMFAInitU2FVerify = "/mfa/init/u2f/verify" - EndpointU2FVerification = "/mfa/u2f/verify" - EndpointMailVerification = "/mail/verification" - EndpointMailVerified = "/mail/verified" - EndpointRegisterOption = "/register/option" - EndpointRegister = "/register" - EndpointExternalRegister = "/register/externalidp" - EndpointExternalRegisterCallback = "/register/externalidp/callback" - EndpointRegisterOrg = "/register/org" - EndpointLogoutDone = "/logout/done" - EndpointLoginSuccess = "/login/success" - EndpointExternalNotFoundOption = "/externaluser/option" + EndpointRoot = "/" + EndpointHealthz = "/healthz" + EndpointReadiness = "/ready" + EndpointLogin = "/login" + EndpointExternalLogin = "/login/externalidp" + EndpointExternalLoginCallback = "/login/externalidp/callback" + EndpointExternalLoginCallbackFormPost = "/login/externalidp/callback/form" + EndpointJWTAuthorize = "/login/jwt/authorize" + EndpointJWTCallback = "/login/jwt/callback" + EndpointLDAPLogin = "/login/ldap" + EndpointLDAPCallback = "/login/ldap/callback" + EndpointPasswordlessLogin = "/login/passwordless" + EndpointPasswordlessRegistration = "/login/passwordless/init" + EndpointPasswordlessPrompt = "/login/passwordless/prompt" + EndpointLoginName = "/loginname" + EndpointUserSelection = "/userselection" + EndpointChangeUsername = "/username/change" + EndpointPassword = "/password" + EndpointInitPassword = "/password/init" + EndpointChangePassword = "/password/change" + EndpointPasswordReset = "/password/reset" + EndpointInitUser = "/user/init" + EndpointMFAVerify = "/mfa/verify" + EndpointMFAPrompt = "/mfa/prompt" + EndpointMFAInitVerify = "/mfa/init/verify" + EndpointMFASMSInitVerify = "/mfa/init/sms/verify" + EndpointMFAOTPVerify = "/mfa/otp/verify" + EndpointMFAInitU2FVerify = "/mfa/init/u2f/verify" + EndpointU2FVerification = "/mfa/u2f/verify" + EndpointMailVerification = "/mail/verification" + EndpointMailVerified = "/mail/verified" + EndpointRegisterOption = "/register/option" + EndpointRegister = "/register" + EndpointExternalRegister = "/register/externalidp" + EndpointExternalRegisterCallback = "/register/externalidp/callback" + EndpointRegisterOrg = "/register/org" + EndpointLogoutDone = "/logout/done" + EndpointLoginSuccess = "/login/success" + EndpointExternalNotFoundOption = "/externaluser/option" EndpointResources = "/resources" EndpointDynamicResources = "/resources/dynamic" @@ -71,6 +72,7 @@ func CreateRouter(login *Login, staticDir http.FileSystem, interceptors ...mux.M router.HandleFunc(EndpointLogin, login.handleLogin).Methods(http.MethodGet, http.MethodPost) router.HandleFunc(EndpointExternalLogin, login.handleExternalLogin).Methods(http.MethodGet) router.HandleFunc(EndpointExternalLoginCallback, login.handleExternalLoginCallback).Methods(http.MethodGet) + router.HandleFunc(EndpointExternalLoginCallbackFormPost, login.handleExternalLoginCallbackForm).Methods(http.MethodPost) router.HandleFunc(EndpointJWTAuthorize, login.handleJWTRequest).Methods(http.MethodGet) router.HandleFunc(EndpointJWTCallback, login.handleJWTCallback).Methods(http.MethodGet) router.HandleFunc(EndpointPasswordlessLogin, login.handlePasswordlessVerification).Methods(http.MethodPost) diff --git a/internal/api/ui/login/static/i18n/bg.yaml b/internal/api/ui/login/static/i18n/bg.yaml index 1a7413e57..819073c5d 100644 --- a/internal/api/ui/login/static/i18n/bg.yaml +++ b/internal/api/ui/login/static/i18n/bg.yaml @@ -360,6 +360,7 @@ Footer: PrivacyPolicy: Политика за поверителност Help: Помогне SupportEmail: Поддръжка на имейл +SignIn: Влезте с {{.Provider}} Errors: Internal: Възникна вътрешна грешка AuthRequest: diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index 01b52da3e..6ae40c99c 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -372,6 +372,8 @@ Footer: Help: Hilfe SupportEmail: Support E-Mail +SignIn: Mit {{.Provider}} anmelden + Errors: Internal: Es ist ein interner Fehler aufgetreten AuthRequest: diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index b174ef74b..8c5d2b773 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -372,6 +372,8 @@ Footer: Help: Help SupportEmail: Support E-mail +SignIn: Sign in with {{.Provider}} + Errors: Internal: An internal error occurred AuthRequest: diff --git a/internal/api/ui/login/static/i18n/es.yaml b/internal/api/ui/login/static/i18n/es.yaml index 7ddb5e743..0976e7a69 100644 --- a/internal/api/ui/login/static/i18n/es.yaml +++ b/internal/api/ui/login/static/i18n/es.yaml @@ -354,6 +354,8 @@ Footer: Help: Ayuda SupportEmail: Email de soporte +SignIn: Iniciar sesión con {{.Provider}} + Errors: Internal: Se produjo un error interno AuthRequest: diff --git a/internal/api/ui/login/static/i18n/fr.yaml b/internal/api/ui/login/static/i18n/fr.yaml index e5a3ac38f..b39bb2cf9 100644 --- a/internal/api/ui/login/static/i18n/fr.yaml +++ b/internal/api/ui/login/static/i18n/fr.yaml @@ -372,6 +372,8 @@ Footer: Help: Aide SupportEmail: E-mail d'assistance +SignIn: Connexion avec {{.Provider}} + Errors: Internal: Une erreur interne s'est produite AuthRequest: diff --git a/internal/api/ui/login/static/i18n/it.yaml b/internal/api/ui/login/static/i18n/it.yaml index 814ca3fcd..9430a8ed3 100644 --- a/internal/api/ui/login/static/i18n/it.yaml +++ b/internal/api/ui/login/static/i18n/it.yaml @@ -372,6 +372,8 @@ Footer: Help: Aiuto SupportEmail: E-mail di supporto +SignIn: Accedi con {{.Provider}} + Errors: Internal: Si è verificato un errore interno AuthRequest: diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index 6a86bae7a..7693d50e1 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -363,6 +363,8 @@ Footer: PrivacyPolicy: プライバシーポリシー Help: ヘルプ +SignIn: '{{.Provider}} でサインイン' + Errors: Internal: 内部でエラーが発生しました AuthRequest: diff --git a/internal/api/ui/login/static/i18n/mk.yaml b/internal/api/ui/login/static/i18n/mk.yaml index de345d013..d0107dec6 100644 --- a/internal/api/ui/login/static/i18n/mk.yaml +++ b/internal/api/ui/login/static/i18n/mk.yaml @@ -372,6 +372,8 @@ Footer: Help: Помош SupportEmail: Е-пошта за поддршка +SignIn: Пријавете се со {{.Provider}} + Errors: Internal: Се појави внатрешна грешка AuthRequest: diff --git a/internal/api/ui/login/static/i18n/pl.yaml b/internal/api/ui/login/static/i18n/pl.yaml index e1f6cecb7..963e0d038 100644 --- a/internal/api/ui/login/static/i18n/pl.yaml +++ b/internal/api/ui/login/static/i18n/pl.yaml @@ -372,6 +372,8 @@ Footer: Help: Pomoc SupportEmail: E-mail wsparcia +SignIn: Zaloguj się, używając konta {{.Provider}} + Errors: Internal: Wewnętrzny błąd AuthRequest: diff --git a/internal/api/ui/login/static/i18n/pt.yaml b/internal/api/ui/login/static/i18n/pt.yaml index dc44c2c45..3b32b2178 100644 --- a/internal/api/ui/login/static/i18n/pt.yaml +++ b/internal/api/ui/login/static/i18n/pt.yaml @@ -366,6 +366,8 @@ Footer: Help: Ajuda SupportEmail: E-mail de suporte +SignIn: Iniciar sessão com a {{.Provider}} + Errors: Internal: Ocorreu um erro interno AuthRequest: diff --git a/internal/api/ui/login/static/i18n/zh.yaml b/internal/api/ui/login/static/i18n/zh.yaml index 30c352b43..315eed8dc 100644 --- a/internal/api/ui/login/static/i18n/zh.yaml +++ b/internal/api/ui/login/static/i18n/zh.yaml @@ -372,6 +372,8 @@ Footer: Help: 帮助 SupportEmail: 支持邮箱 +SignIn: 通过 {{.Provider}} 登录 + Errors: Internal: 发生了内部错误 AuthRequest: diff --git a/internal/api/ui/login/static/resources/images/idp/apple-dark.svg b/internal/api/ui/login/static/resources/images/idp/apple-dark.svg new file mode 100755 index 000000000..0fed0b43e --- /dev/null +++ b/internal/api/ui/login/static/resources/images/idp/apple-dark.svg @@ -0,0 +1,10 @@ + + + + White Logo Square + Created with Sketch. + + + + + diff --git a/internal/api/ui/login/static/resources/images/idp/apple.svg b/internal/api/ui/login/static/resources/images/idp/apple.svg new file mode 100755 index 000000000..e2ac901df --- /dev/null +++ b/internal/api/ui/login/static/resources/images/idp/apple.svg @@ -0,0 +1,10 @@ + + + + Black Logo Square + Created with Sketch. + + + + + diff --git a/internal/api/ui/login/static/resources/themes/scss/styles/identity_provider/identity_provider_base.scss b/internal/api/ui/login/static/resources/themes/scss/styles/identity_provider/identity_provider_base.scss index a558e1435..480064098 100644 --- a/internal/api/ui/login/static/resources/themes/scss/styles/identity_provider/identity_provider_base.scss +++ b/internal/api/ui/login/static/resources/themes/scss/styles/identity_provider/identity_provider_base.scss @@ -2,6 +2,7 @@ $lgn-idp-margin: 0.5rem 0; $lgn-idp-padding: 0 1px; $lgn-idp-provider-name-line-height: 36px; $lgn-idp-border-radius: 0.5rem; +$lgn-idp-logo-size: 46px; @mixin lgn-idp-base { display: block; @@ -17,14 +18,14 @@ $lgn-idp-border-radius: 0.5rem; transition: border-color 0.2s ease-in-out; span.logo { - height: 46px; - width: 46px; + height: $lgn-idp-logo-size; + width: $lgn-idp-logo-size; } span.provider-name { line-height: $lgn-idp-provider-name-line-height; - position: absolute; - left: 50%; + position: relative; + left: calc(50% - $lgn-idp-logo-size); transform: translateX(-50%); } @@ -75,4 +76,17 @@ $lgn-idp-border-radius: 0.5rem; border-radius: 5px; } } + + &.apple { + span.logo { + height: 46px; + width: 46px; + background-image: var(--apple-image-src); + background-size: 25px; + background-position: center; + background-repeat: no-repeat; + border-radius: 5px; + transform: translateY(-2px); + } + } } diff --git a/internal/api/ui/login/static/resources/themes/scss/styles/vars.scss b/internal/api/ui/login/static/resources/themes/scss/styles/vars.scss index 3242e5f45..5a5ee651e 100644 --- a/internal/api/ui/login/static/resources/themes/scss/styles/vars.scss +++ b/internal/api/ui/login/static/resources/themes/scss/styles/vars.scss @@ -117,6 +117,8 @@ --zitadel-color-github-background: #ffffff; --zitadel-color-gitlab-text: #8b8d8d; --zitadel-color-gitlab-background: #ffffff; + --zitadel-color-apple-text: #8b8d8d; + --zitadel-color-apple-background: #ffffff; --zitadel-color-qr: var(--zitadel-color-black); --zitadel-color-qr-background: var(--zitadel-color-white); @@ -125,6 +127,7 @@ --github-image-src: url(../../../images/idp/github.png); --gitlab-image-src: url(../../../images/idp/gitlab.png); --azure-image-src: url(../../../images/idp/ms.svg); + --apple-image-src: url(../../../images/idp/apple.svg); } .lgn-dark-theme { @@ -227,9 +230,12 @@ --zitadel-color-github-background: #ffffff; --zitadel-color-gitlab-text: #8b8d8d; --zitadel-color-gitlab-background: #ffffff; + --zitadel-color-apple-text: #8b8d8d; + --zitadel-color-apple-background: #ffffff; --google-image-src: url(../../../images/idp/google.png); --github-image-src: url(../../../images/idp/github-white.png); --gitlab-image-src: url(../../../images/idp/gitlab.png); --azure-image-src: url(../../../images/idp/ms.svg); + --apple-image-src: url(../../../images/idp/apple-dark.svg); } diff --git a/internal/api/ui/login/static/templates/login.html b/internal/api/ui/login/static/templates/login.html index a8e0626eb..72837c7f9 100644 --- a/internal/api/ui/login/static/templates/login.html +++ b/internal/api/ui/login/static/templates/login.html @@ -52,7 +52,11 @@
+ {{if $provider.IDPType.IsSignInButton}} + {{t "SignIn" "Provider" $provider.DisplayName}} + {{else}} {{$provider.DisplayName}} + {{end}} {{end}} diff --git a/internal/api/ui/login/static/templates/register_option.html b/internal/api/ui/login/static/templates/register_option.html index 24de130a3..67cd97279 100644 --- a/internal/api/ui/login/static/templates/register_option.html +++ b/internal/api/ui/login/static/templates/register_option.html @@ -29,7 +29,11 @@ + {{if $provider.IDPType.IsSignInButton}} + {{t "SignIn" "Provider" $provider.DisplayName}} + {{else}} {{$provider.DisplayName}} + {{end}} {{end}} {{end}} diff --git a/internal/command/idp.go b/internal/command/idp.go index 4e34aa6e4..5a21596a2 100644 --- a/internal/command/idp.go +++ b/internal/command/idp.go @@ -110,6 +110,16 @@ type LDAPProvider struct { IDPOptions idp.Options } +type AppleProvider struct { + Name string + ClientID string + TeamID string + KeyID string + PrivateKey []byte + Scopes []string + IDPOptions idp.Options +} + func ExistsIDP(ctx context.Context, filter preparation.FilterToQueryReducer, id, orgID string) (exists bool, err error) { writeModel := NewOrgIDPRemoveWriteModel(orgID, id) events, err := filter(ctx, writeModel.Query()) diff --git a/internal/command/idp_intent.go b/internal/command/idp_intent.go index 9cbc1beb9..21b4cd7d9 100644 --- a/internal/command/idp_intent.go +++ b/internal/command/idp_intent.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/apple" "github.com/zitadel/zitadel/internal/idp/providers/azuread" "github.com/zitadel/zitadel/internal/idp/providers/jwt" "github.com/zitadel/zitadel/internal/idp/providers/oauth" @@ -215,6 +216,8 @@ func tokensForSucceededIDPIntent(session idp.Session, encryptionAlg crypto.Encry tokens = s.Tokens case *azuread.Session: tokens = s.Tokens + case *apple.Session: + tokens = s.Tokens default: return nil, "", nil } diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go index e01d83124..fea291c44 100644 --- a/internal/command/idp_model.go +++ b/internal/command/idp_model.go @@ -3,6 +3,7 @@ package command import ( "net/http" "reflect" + "slices" "time" "github.com/zitadel/logging" @@ -14,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" providers "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/apple" "github.com/zitadel/zitadel/internal/idp/providers/azuread" "github.com/zitadel/zitadel/internal/idp/providers/github" "github.com/zitadel/zitadel/internal/idp/providers/gitlab" @@ -1587,6 +1589,138 @@ func (wm *LDAPIDPWriteModel) GetProviderOptions() idp.Options { return wm.Options } +type AppleIDPWriteModel struct { + eventstore.WriteModel + + ID string + Name string + ClientID string + TeamID string + KeyID string + PrivateKey *crypto.CryptoValue + Scopes []string + idp.Options + + State domain.IDPState +} + +func (wm *AppleIDPWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *idp.AppleIDPAddedEvent: + wm.reduceAddedEvent(e) + case *idp.AppleIDPChangedEvent: + wm.reduceChangedEvent(e) + case *idp.RemovedEvent: + wm.State = domain.IDPStateRemoved + } + } + return wm.WriteModel.Reduce() +} + +func (wm *AppleIDPWriteModel) reduceAddedEvent(e *idp.AppleIDPAddedEvent) { + wm.Name = e.Name + wm.ClientID = e.ClientID + wm.TeamID = e.TeamID + wm.KeyID = e.KeyID + wm.PrivateKey = e.PrivateKey + wm.Scopes = e.Scopes + wm.Options = e.Options + wm.State = domain.IDPStateActive +} + +func (wm *AppleIDPWriteModel) reduceChangedEvent(e *idp.AppleIDPChangedEvent) { + if e.Name != nil { + wm.Name = *e.Name + } + if e.ClientID != nil { + wm.ClientID = *e.ClientID + } + if e.PrivateKey != nil { + wm.PrivateKey = e.PrivateKey + } + if e.Scopes != nil { + wm.Scopes = e.Scopes + } + wm.Options.ReduceChanges(e.OptionChanges) +} + +func (wm *AppleIDPWriteModel) NewChanges( + name string, + clientID string, + teamID string, + keyID string, + privateKey []byte, + secretCrypto crypto.Crypto, + scopes []string, + options idp.Options, +) ([]idp.AppleIDPChanges, error) { + changes := make([]idp.AppleIDPChanges, 0) + var encryptedKey *crypto.CryptoValue + var err error + if len(privateKey) != 0 { + encryptedKey, err = crypto.Crypt(privateKey, secretCrypto) + if err != nil { + return nil, err + } + changes = append(changes, idp.ChangeApplePrivateKey(encryptedKey)) + } + if wm.Name != name { + changes = append(changes, idp.ChangeAppleName(name)) + } + if wm.ClientID != clientID { + changes = append(changes, idp.ChangeAppleClientID(clientID)) + } + if wm.TeamID != teamID { + changes = append(changes, idp.ChangeAppleTeamID(teamID)) + } + if wm.KeyID != keyID { + changes = append(changes, idp.ChangeAppleKeyID(keyID)) + } + if slices.Compare(wm.Scopes, scopes) != 0 { + changes = append(changes, idp.ChangeAppleScopes(scopes)) + } + + opts := wm.Options.Changes(options) + if !opts.IsZero() { + changes = append(changes, idp.ChangeAppleOptions(opts)) + } + return changes, nil +} + +func (wm *AppleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + privateKey, err := crypto.Decrypt(wm.PrivateKey, idpAlg) + if err != nil { + return nil, err + } + opts := make([]oidc.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + opts = append(opts, oidc.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, oidc.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, oidc.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, oidc.WithAutoUpdate()) + } + return apple.New( + wm.ClientID, + wm.TeamID, + wm.KeyID, + callbackURL, + privateKey, + wm.Scopes, + opts..., + ) +} + +func (wm *AppleIDPWriteModel) GetProviderOptions() idp.Options { + return wm.Options +} + type IDPRemoveWriteModel struct { eventstore.WriteModel @@ -1617,6 +1751,8 @@ func (wm *IDPRemoveWriteModel) Reduce() error { wm.reduceAdded(e.ID) case *idp.LDAPIDPAddedEvent: wm.reduceAdded(e.ID) + case *idp.AppleIDPAddedEvent: + wm.reduceAdded(e.ID) case *idp.RemovedEvent: wm.reduceRemoved(e.ID) case *idpconfig.IDPConfigAddedEvent: @@ -1699,6 +1835,10 @@ func (wm *IDPTypeWriteModel) Reduce() error { wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate()) case *org.LDAPIDPAddedEvent: wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate()) + case *instance.AppleIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeApple, e.Aggregate()) + case *org.AppleIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeApple, e.Aggregate()) case *instance.OIDCIDPMigratedAzureADEvent: wm.reduceChanged(e.ID, domain.IDPTypeAzureAD) case *org.OIDCIDPMigratedAzureADEvent: @@ -1774,6 +1914,7 @@ func (wm *IDPTypeWriteModel) Query() *eventstore.SearchQueryBuilder { instance.GitLabSelfHostedIDPAddedEventType, instance.GoogleIDPAddedEventType, instance.LDAPIDPAddedEventType, + instance.AppleIDPAddedEventType, instance.OIDCIDPMigratedAzureADEventType, instance.OIDCIDPMigratedGoogleEventType, instance.IDPRemovedEventType, @@ -1792,6 +1933,7 @@ func (wm *IDPTypeWriteModel) Query() *eventstore.SearchQueryBuilder { org.GitLabSelfHostedIDPAddedEventType, org.GoogleIDPAddedEventType, org.LDAPIDPAddedEventType, + org.AppleIDPAddedEventType, org.OIDCIDPMigratedAzureADEventType, org.OIDCIDPMigratedGoogleEventType, org.IDPRemovedEventType, @@ -1859,6 +2001,8 @@ func NewAllIDPWriteModel(resourceOwner string, instanceBool bool, id string, idp writeModel.model = NewGitLabSelfHostedInstanceIDPWriteModel(resourceOwner, id) case domain.IDPTypeGoogle: writeModel.model = NewGoogleInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeApple: + writeModel.model = NewAppleInstanceIDPWriteModel(resourceOwner, id) case domain.IDPTypeUnspecified: fallthrough default: @@ -1886,6 +2030,8 @@ func NewAllIDPWriteModel(resourceOwner string, instanceBool bool, id string, idp writeModel.model = NewGitLabSelfHostedOrgIDPWriteModel(resourceOwner, id) case domain.IDPTypeGoogle: writeModel.model = NewGoogleOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeApple: + writeModel.model = NewAppleOrgIDPWriteModel(resourceOwner, id) case domain.IDPTypeUnspecified: fallthrough default: diff --git a/internal/command/instance_idp.go b/internal/command/instance_idp.go index ea6534970..a6d429f6c 100644 --- a/internal/command/instance_idp.go +++ b/internal/command/instance_idp.go @@ -467,6 +467,48 @@ func (c *Commands) UpdateInstanceLDAPProvider(ctx context.Context, id string, pr return pushedEventsToObjectDetails(pushedEvents), nil } +func (c *Commands) AddInstanceAppleProvider(ctx context.Context, provider AppleProvider) (string, *domain.ObjectDetails, error) { + instanceID := authz.GetInstance(ctx).InstanceID() + instanceAgg := instance.NewAggregate(instanceID) + id, err := c.idGenerator.Next() + if err != nil { + return "", nil, err + } + writeModel := NewAppleInstanceIDPWriteModel(instanceID, id) + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareAddInstanceAppleProvider(instanceAgg, writeModel, provider)) + if err != nil { + return "", nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return "", nil, err + } + return id, pushedEventsToObjectDetails(pushedEvents), nil +} + +func (c *Commands) UpdateInstanceAppleProvider(ctx context.Context, id string, provider AppleProvider) (*domain.ObjectDetails, error) { + instanceID := authz.GetInstance(ctx).InstanceID() + instanceAgg := instance.NewAggregate(instanceID) + writeModel := NewAppleInstanceIDPWriteModel(instanceID, id) + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareUpdateInstanceAppleProvider(instanceAgg, writeModel, provider)) + if err != nil { + return nil, err + } + if len(cmds) == 0 { + // no change, so return directly + return &domain.ObjectDetails{ + Sequence: writeModel.ProcessedSequence, + EventDate: writeModel.ChangeDate, + ResourceOwner: writeModel.ResourceOwner, + }, nil + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + return pushedEventsToObjectDetails(pushedEvents), nil +} + func (c *Commands) DeleteInstanceProvider(ctx context.Context, id string) (*domain.ObjectDetails, error) { instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID()) cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareDeleteInstanceProvider(instanceAgg, id)) @@ -1518,6 +1560,98 @@ func (c *Commands) prepareUpdateInstanceLDAPProvider(a *instance.Aggregate, writ } } +func (c *Commands) prepareAddInstanceAppleProvider(a *instance.Aggregate, writeModel *InstanceAppleIDPWriteModel, provider AppleProvider) preparation.Validation { + return func() (preparation.CreateCommands, error) { + if provider.ClientID = strings.TrimSpace(provider.ClientID); provider.ClientID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-jkn3w", "Errors.IDP.ClientIDMissing") + } + if provider.TeamID = strings.TrimSpace(provider.TeamID); provider.TeamID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-Ffg32", "Errors.IDP.TeamIDMissing") + } + if provider.KeyID = strings.TrimSpace(provider.KeyID); provider.KeyID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-GDjm5", "Errors.IDP.KeyIDMissing") + } + if len(provider.PrivateKey) == 0 { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-GVD4n", "Errors.IDP.PrivateKeyMissing") + } + return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + events, err := filter(ctx, writeModel.Query()) + if err != nil { + return nil, err + } + writeModel.AppendEvents(events...) + if err = writeModel.Reduce(); err != nil { + return nil, err + } + privateKey, err := crypto.Encrypt(provider.PrivateKey, c.idpConfigEncryption) + if err != nil { + return nil, err + } + return []eventstore.Command{ + instance.NewAppleIDPAddedEvent( + ctx, + &a.Aggregate, + writeModel.ID, + provider.Name, + provider.ClientID, + provider.TeamID, + provider.KeyID, + privateKey, + provider.Scopes, + provider.IDPOptions, + ), + }, nil + }, nil + } +} + +func (c *Commands) prepareUpdateInstanceAppleProvider(a *instance.Aggregate, writeModel *InstanceAppleIDPWriteModel, provider AppleProvider) preparation.Validation { + return func() (preparation.CreateCommands, error) { + if writeModel.ID = strings.TrimSpace(writeModel.ID); writeModel.ID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-FRHBH", "Errors.IDMissing") + } + if provider.ClientID = strings.TrimSpace(provider.ClientID); provider.ClientID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-SFm4l", "Errors.IDP.ClientIDMissing") + } + if provider.TeamID = strings.TrimSpace(provider.TeamID); provider.TeamID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-SG34t", "Errors.IDP.TeamIDMissing") + } + if provider.KeyID = strings.TrimSpace(provider.KeyID); provider.KeyID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "INST-Gh4z2", "Errors.IDP.KeyIDMissing") + } + return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + events, err := filter(ctx, writeModel.Query()) + if err != nil { + return nil, err + } + writeModel.AppendEvents(events...) + if err = writeModel.Reduce(); err != nil { + return nil, err + } + if !writeModel.State.Exists() { + return nil, caos_errs.ThrowNotFound(nil, "INST-SG3bh", "Errors.IDPConfig.NotExisting") + } + event, err := writeModel.NewChangedEvent( + ctx, + &a.Aggregate, + writeModel.ID, + provider.Name, + provider.ClientID, + provider.TeamID, + provider.KeyID, + provider.PrivateKey, + c.idpConfigEncryption, + provider.Scopes, + provider.IDPOptions, + ) + if err != nil || event == nil { + return nil, err + } + return []eventstore.Command{event}, nil + }, nil + } +} + func (c *Commands) prepareDeleteInstanceProvider(a *instance.Aggregate, id string) preparation.Validation { return func() (preparation.CreateCommands, error) { return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { diff --git a/internal/command/instance_idp_model.go b/internal/command/instance_idp_model.go index a0e51648d..1d1d33d31 100644 --- a/internal/command/instance_idp_model.go +++ b/internal/command/instance_idp_model.go @@ -793,6 +793,73 @@ func (wm *InstanceLDAPIDPWriteModel) NewChangedEvent( return instance.NewLDAPIDPChangedEvent(ctx, aggregate, id, changes) } +type InstanceAppleIDPWriteModel struct { + AppleIDPWriteModel +} + +func NewAppleInstanceIDPWriteModel(instanceID, id string) *InstanceAppleIDPWriteModel { + return &InstanceAppleIDPWriteModel{ + AppleIDPWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: instanceID, + ResourceOwner: instanceID, + }, + ID: id, + }, + } +} + +func (wm *InstanceAppleIDPWriteModel) AppendEvents(events ...eventstore.Event) { + for _, event := range events { + switch e := event.(type) { + case *instance.AppleIDPAddedEvent: + wm.AppleIDPWriteModel.AppendEvents(&e.AppleIDPAddedEvent) + case *instance.AppleIDPChangedEvent: + wm.AppleIDPWriteModel.AppendEvents(&e.AppleIDPChangedEvent) + case *instance.IDPRemovedEvent: + wm.AppleIDPWriteModel.AppendEvents(&e.RemovedEvent) + default: + wm.AppleIDPWriteModel.AppendEvents(e) + } + } +} + +func (wm *InstanceAppleIDPWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(instance.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + instance.AppleIDPAddedEventType, + instance.AppleIDPChangedEventType, + instance.IDPRemovedEventType, + ). + EventData(map[string]interface{}{"id": wm.ID}). + Builder() +} + +func (wm *InstanceAppleIDPWriteModel) NewChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, + name, + clientID, + teamID, + keyID string, + privateKey []byte, + secretCrypto crypto.Crypto, + scopes []string, + options idp.Options, +) (*instance.AppleIDPChangedEvent, error) { + + changes, err := wm.AppleIDPWriteModel.NewChanges(name, clientID, teamID, keyID, privateKey, secretCrypto, scopes, options) + if err != nil || len(changes) == 0 { + return nil, err + } + return instance.NewAppleIDPChangedEvent(ctx, aggregate, id, changes) +} + type InstanceIDPRemoveWriteModel struct { IDPRemoveWriteModel } @@ -832,6 +899,8 @@ func (wm *InstanceIDPRemoveWriteModel) AppendEvents(events ...eventstore.Event) wm.IDPRemoveWriteModel.AppendEvents(&e.GoogleIDPAddedEvent) case *instance.LDAPIDPAddedEvent: wm.IDPRemoveWriteModel.AppendEvents(&e.LDAPIDPAddedEvent) + case *instance.AppleIDPAddedEvent: + wm.IDPRemoveWriteModel.AppendEvents(&e.AppleIDPAddedEvent) case *instance.IDPRemovedEvent: wm.IDPRemoveWriteModel.AppendEvents(&e.RemovedEvent) case *instance.IDPConfigAddedEvent: @@ -861,6 +930,7 @@ func (wm *InstanceIDPRemoveWriteModel) Query() *eventstore.SearchQueryBuilder { instance.GitLabSelfHostedIDPAddedEventType, instance.GoogleIDPAddedEventType, instance.LDAPIDPAddedEventType, + instance.AppleIDPAddedEventType, instance.IDPRemovedEventType, ). EventData(map[string]interface{}{"id": wm.ID}). diff --git a/internal/command/instance_idp_test.go b/internal/command/instance_idp_test.go index 9b9198fd4..794109cfa 100644 --- a/internal/command/instance_idp_test.go +++ b/internal/command/instance_idp_test.go @@ -4857,3 +4857,464 @@ func TestCommandSide_UpdateInstanceLDAPIDP(t *testing.T) { }) } } + +func TestCommandSide_AddInstanceAppleIDP(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + secretCrypto crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + provider AppleProvider + } + type res struct { + id string + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "invalid clientID", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: AppleProvider{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-jkn3w", "Errors.IDP.ClientIDMissing")) + }, + }, + }, + { + "invalid teamID", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: AppleProvider{ + ClientID: "clientID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-Ffg32", "Errors.IDP.TeamIDMissing")) + }, + }, + }, + { + "invalid keyID", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-GDjm5", "Errors.IDP.KeyIDMissing")) + }, + }, + }, + { + "invalid privateKey", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-GVD4n", "Errors.IDP.PrivateKeyMissing")) + }, + }, + }, + { + name: "ok", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "instance1", + instance.NewAppleIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + nil, + idp.Options{}, + )), + }, + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + PrivateKey: []byte("privateKey"), + }, + }, + res: res{ + id: "id1", + want: &domain.ObjectDetails{ResourceOwner: "instance1"}, + }, + }, + { + name: "ok all set", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "instance1", + instance.NewAppleIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + []string{"name", "email"}, + idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + )), + }, + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + PrivateKey: []byte("privateKey"), + Scopes: []string{"name", "email"}, + IDPOptions: idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + }, + }, + res: res{ + id: "id1", + want: &domain.ObjectDetails{ResourceOwner: "instance1"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + idpConfigEncryption: tt.fields.secretCrypto, + } + id, got, err := c.AddInstanceAppleProvider(tt.args.ctx, tt.args.provider) + 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.id, id) + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_UpdateInstanceAppleIDP(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + secretCrypto crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + id string + provider AppleProvider + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "invalid id", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + provider: AppleProvider{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-FRHBH", "Errors.IDMissing")) + }, + }, + }, + { + "invalid clientID", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + id: "id1", + provider: AppleProvider{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-SFm4l", "Errors.IDP.ClientIDMissing")) + }, + }, + }, + { + "invalid teamID", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-SG34t", "Errors.IDP.TeamIDMissing")) + }, + }, + }, + { + "invalid keyID", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "INST-Gh4z2", "Errors.IDP.KeyIDMissing")) + }, + }, + }, + { + name: "not found", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + }, + }, + res: res{ + err: caos_errors.IsNotFound, + }, + }, + { + name: "no changes", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + instance.NewAppleIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + nil, + idp.Options{}, + )), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + }, + }, + res: res{ + want: &domain.ObjectDetails{ResourceOwner: "instance1"}, + }, + }, + { + name: "change ok", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + instance.NewAppleIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + nil, + idp.Options{}, + )), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID( + "instance1", + func() eventstore.Command { + t := true + event, _ := instance.NewAppleIDPChangedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate, + "id1", + []idp.AppleIDPChanges{ + idp.ChangeAppleClientID("clientID2"), + idp.ChangeAppleTeamID("teamID2"), + idp.ChangeAppleKeyID("keyID2"), + idp.ChangeApplePrivateKey(&crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("newPrivateKey"), + }), + idp.ChangeAppleScopes([]string{"name", "email"}), + idp.ChangeAppleOptions(idp.OptionChanges{ + IsCreationAllowed: &t, + IsLinkingAllowed: &t, + IsAutoCreation: &t, + IsAutoUpdate: &t, + }), + }, + ) + return event + }(), + ), + }, + ), + ), + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + id: "id1", + provider: AppleProvider{ + ClientID: "clientID2", + TeamID: "teamID2", + KeyID: "keyID2", + PrivateKey: []byte("newPrivateKey"), + Scopes: []string{"name", "email"}, + IDPOptions: idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ResourceOwner: "instance1"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idpConfigEncryption: tt.fields.secretCrypto, + } + got, err := c.UpdateInstanceAppleProvider(tt.args.ctx, tt.args.id, tt.args.provider) + 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) + } + }) + } +} diff --git a/internal/command/org_idp.go b/internal/command/org_idp.go index 2fc0bc048..66afee2ec 100644 --- a/internal/command/org_idp.go +++ b/internal/command/org_idp.go @@ -444,6 +444,46 @@ func (c *Commands) UpdateOrgLDAPProvider(ctx context.Context, resourceOwner, id return pushedEventsToObjectDetails(pushedEvents), nil } +func (c *Commands) AddOrgAppleProvider(ctx context.Context, resourceOwner string, provider AppleProvider) (string, *domain.ObjectDetails, error) { + orgAgg := org.NewAggregate(resourceOwner) + id, err := c.idGenerator.Next() + if err != nil { + return "", nil, err + } + writeModel := NewAppleOrgIDPWriteModel(resourceOwner, id) + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareAddOrgAppleProvider(orgAgg, writeModel, provider)) + if err != nil { + return "", nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return "", nil, err + } + return id, pushedEventsToObjectDetails(pushedEvents), nil +} + +func (c *Commands) UpdateOrgAppleProvider(ctx context.Context, resourceOwner, id string, provider AppleProvider) (*domain.ObjectDetails, error) { + orgAgg := org.NewAggregate(resourceOwner) + writeModel := NewAppleOrgIDPWriteModel(resourceOwner, id) + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareUpdateOrgAppleProvider(orgAgg, writeModel, provider)) + if err != nil { + return nil, err + } + if len(cmds) == 0 { + // no change, so return directly + return &domain.ObjectDetails{ + Sequence: writeModel.ProcessedSequence, + EventDate: writeModel.ChangeDate, + ResourceOwner: writeModel.ResourceOwner, + }, nil + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + return pushedEventsToObjectDetails(pushedEvents), nil +} + func (c *Commands) DeleteOrgProvider(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { orgAgg := org.NewAggregate(resourceOwner) cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareDeleteOrgProvider(orgAgg, resourceOwner, id)) @@ -1507,6 +1547,98 @@ func (c *Commands) prepareUpdateOrgLDAPProvider(a *org.Aggregate, writeModel *Or } } +func (c *Commands) prepareAddOrgAppleProvider(a *org.Aggregate, writeModel *OrgAppleIDPWriteModel, provider AppleProvider) preparation.Validation { + return func() (preparation.CreateCommands, error) { + if provider.ClientID = strings.TrimSpace(provider.ClientID); provider.ClientID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-jkn3w", "Errors.IDP.ClientIDMissing") + } + if provider.TeamID = strings.TrimSpace(provider.TeamID); provider.TeamID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-Ffg32", "Errors.IDP.TeamIDMissing") + } + if provider.KeyID = strings.TrimSpace(provider.KeyID); provider.KeyID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-GDjm5", "Errors.IDP.KeyIDMissing") + } + if len(provider.PrivateKey) == 0 { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-GVD4n", "Errors.IDP.PrivateKeyMissing") + } + return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + events, err := filter(ctx, writeModel.Query()) + if err != nil { + return nil, err + } + writeModel.AppendEvents(events...) + if err = writeModel.Reduce(); err != nil { + return nil, err + } + privateKey, err := crypto.Encrypt(provider.PrivateKey, c.idpConfigEncryption) + if err != nil { + return nil, err + } + return []eventstore.Command{ + org.NewAppleIDPAddedEvent( + ctx, + &a.Aggregate, + writeModel.ID, + provider.Name, + provider.ClientID, + provider.TeamID, + provider.KeyID, + privateKey, + provider.Scopes, + provider.IDPOptions, + ), + }, nil + }, nil + } +} + +func (c *Commands) prepareUpdateOrgAppleProvider(a *org.Aggregate, writeModel *OrgAppleIDPWriteModel, provider AppleProvider) preparation.Validation { + return func() (preparation.CreateCommands, error) { + if writeModel.ID = strings.TrimSpace(writeModel.ID); writeModel.ID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-FRHBH", "Errors.IDMissing") + } + if provider.ClientID = strings.TrimSpace(provider.ClientID); provider.ClientID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-SFm4l", "Errors.IDP.ClientIDMissing") + } + if provider.TeamID = strings.TrimSpace(provider.TeamID); provider.TeamID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-SG34t", "Errors.IDP.TeamIDMissing") + } + if provider.KeyID = strings.TrimSpace(provider.KeyID); provider.KeyID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "ORG-Gh4z2", "Errors.IDP.KeyIDMissing") + } + return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + events, err := filter(ctx, writeModel.Query()) + if err != nil { + return nil, err + } + writeModel.AppendEvents(events...) + if err = writeModel.Reduce(); err != nil { + return nil, err + } + if !writeModel.State.Exists() { + return nil, caos_errs.ThrowNotFound(nil, "ORG-SG3bh", "Errors.IDPConfig.NotExisting") + } + event, err := writeModel.NewChangedEvent( + ctx, + &a.Aggregate, + writeModel.ID, + provider.Name, + provider.ClientID, + provider.TeamID, + provider.KeyID, + provider.PrivateKey, + c.idpConfigEncryption, + provider.Scopes, + provider.IDPOptions, + ) + if err != nil || event == nil { + return nil, err + } + return []eventstore.Command{event}, nil + }, nil + } +} + func (c *Commands) prepareDeleteOrgProvider(a *org.Aggregate, resourceOwner, id string) preparation.Validation { return func() (preparation.CreateCommands, error) { return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { diff --git a/internal/command/org_idp_model.go b/internal/command/org_idp_model.go index 5f121dbbf..a543de507 100644 --- a/internal/command/org_idp_model.go +++ b/internal/command/org_idp_model.go @@ -803,6 +803,73 @@ func (wm *OrgLDAPIDPWriteModel) NewChangedEvent( return org.NewLDAPIDPChangedEvent(ctx, aggregate, id, changes) } +type OrgAppleIDPWriteModel struct { + AppleIDPWriteModel +} + +func NewAppleOrgIDPWriteModel(orgID, id string) *OrgAppleIDPWriteModel { + return &OrgAppleIDPWriteModel{ + AppleIDPWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: orgID, + ResourceOwner: orgID, + }, + ID: id, + }, + } +} + +func (wm *OrgAppleIDPWriteModel) AppendEvents(events ...eventstore.Event) { + for _, event := range events { + switch e := event.(type) { + case *org.AppleIDPAddedEvent: + wm.AppleIDPWriteModel.AppendEvents(&e.AppleIDPAddedEvent) + case *org.AppleIDPChangedEvent: + wm.AppleIDPWriteModel.AppendEvents(&e.AppleIDPChangedEvent) + case *org.IDPRemovedEvent: + wm.AppleIDPWriteModel.AppendEvents(&e.RemovedEvent) + default: + wm.AppleIDPWriteModel.AppendEvents(e) + } + } +} + +func (wm *OrgAppleIDPWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(org.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + org.AppleIDPAddedEventType, + org.AppleIDPChangedEventType, + org.IDPRemovedEventType, + ). + EventData(map[string]interface{}{"id": wm.ID}). + Builder() +} + +func (wm *OrgAppleIDPWriteModel) NewChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, + name, + clientID, + teamID, + keyID string, + privateKey []byte, + secretCrypto crypto.Crypto, + scopes []string, + options idp.Options, +) (*org.AppleIDPChangedEvent, error) { + + changes, err := wm.AppleIDPWriteModel.NewChanges(name, clientID, teamID, keyID, privateKey, secretCrypto, scopes, options) + if err != nil || len(changes) == 0 { + return nil, err + } + return org.NewAppleIDPChangedEvent(ctx, aggregate, id, changes) +} + type OrgIDPRemoveWriteModel struct { IDPRemoveWriteModel } @@ -842,6 +909,8 @@ func (wm *OrgIDPRemoveWriteModel) AppendEvents(events ...eventstore.Event) { wm.IDPRemoveWriteModel.AppendEvents(&e.GoogleIDPAddedEvent) case *org.LDAPIDPAddedEvent: wm.IDPRemoveWriteModel.AppendEvents(&e.LDAPIDPAddedEvent) + case *org.AppleIDPAddedEvent: + wm.IDPRemoveWriteModel.AppendEvents(&e.AppleIDPAddedEvent) case *org.IDPRemovedEvent: wm.IDPRemoveWriteModel.AppendEvents(&e.RemovedEvent) case *org.IDPConfigAddedEvent: @@ -871,6 +940,7 @@ func (wm *OrgIDPRemoveWriteModel) Query() *eventstore.SearchQueryBuilder { org.GitLabSelfHostedIDPAddedEventType, org.GoogleIDPAddedEventType, org.LDAPIDPAddedEventType, + org.AppleIDPAddedEventType, org.IDPRemovedEventType, ). EventData(map[string]interface{}{"id": wm.ID}). diff --git a/internal/command/org_idp_test.go b/internal/command/org_idp_test.go index 063bb70b2..641abb90b 100644 --- a/internal/command/org_idp_test.go +++ b/internal/command/org_idp_test.go @@ -4926,6 +4926,473 @@ func TestCommandSide_UpdateOrgLDAPIDP(t *testing.T) { } } +func TestCommandSide_AddOrgAppleIDP(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + secretCrypto crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + resourceOwner string + provider AppleProvider + } + type res struct { + id string + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "invalid clientID", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: AppleProvider{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-jkn3w", "Errors.IDP.ClientIDMissing")) + }, + }, + }, + { + "invalid teamID", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: AppleProvider{ + ClientID: "clientID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-Ffg32", "Errors.IDP.TeamIDMissing")) + }, + }, + }, + { + "invalid keyID", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-GDjm5", "Errors.IDP.KeyIDMissing")) + }, + }, + }, + { + "invalid privateKey", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-GVD4n", "Errors.IDP.PrivateKeyMissing")) + }, + }, + }, + { + name: "ok", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + expectPush( + eventPusherToEvents( + org.NewAppleIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + nil, + idp.Options{}, + )), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + PrivateKey: []byte("privateKey"), + }, + }, + res: res{ + id: "id1", + want: &domain.ObjectDetails{ResourceOwner: "org1"}, + }, + }, + { + name: "ok all set", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + expectPush( + eventPusherToEvents( + org.NewAppleIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + []string{"name", "email"}, + idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + )), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"), + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + PrivateKey: []byte("privateKey"), + Scopes: []string{"name", "email"}, + IDPOptions: idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + }, + }, + res: res{ + id: "id1", + want: &domain.ObjectDetails{ResourceOwner: "org1"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + idpConfigEncryption: tt.fields.secretCrypto, + } + id, got, err := c.AddOrgAppleProvider(tt.args.ctx, tt.args.resourceOwner, tt.args.provider) + 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.id, id) + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_UpdateOrgAppleIDP(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + secretCrypto crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + resourceOwner string + id string + provider AppleProvider + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "invalid id", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + provider: AppleProvider{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-FRHBH", "Errors.IDMissing")) + }, + }, + }, + { + "invalid clientID", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + id: "id1", + provider: AppleProvider{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-SFm4l", "Errors.IDP.ClientIDMissing")) + }, + }, + }, + { + "invalid teamID", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-SG34t", "Errors.IDP.TeamIDMissing")) + }, + }, + }, + { + "invalid keyID", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + resourceOwner: "org1", + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, caos_errors.ThrowInvalidArgument(nil, "ORG-Gh4z2", "Errors.IDP.KeyIDMissing")) + }, + }, + }, + { + name: "not found", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "org1", + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + }, + }, + res: res{ + err: caos_errors.IsNotFound, + }, + }, + { + name: "no changes", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + org.NewAppleIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + nil, + idp.Options{}, + )), + ), + ), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "org1", + id: "id1", + provider: AppleProvider{ + ClientID: "clientID", + TeamID: "teamID", + KeyID: "keyID", + }, + }, + res: res{ + want: &domain.ObjectDetails{ResourceOwner: "org1"}, + }, + }, + { + name: "change ok", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + org.NewAppleIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, + "id1", + "", + "clientID", + "teamID", + "keyID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("privateKey"), + }, + nil, + idp.Options{}, + )), + ), + expectPush( + eventPusherToEvents( + func() eventstore.Command { + t := true + event, _ := org.NewAppleIDPChangedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, + "id1", + []idp.AppleIDPChanges{ + idp.ChangeAppleClientID("clientID2"), + idp.ChangeAppleTeamID("teamID2"), + idp.ChangeAppleKeyID("keyID2"), + idp.ChangeApplePrivateKey(&crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("newPrivateKey"), + }), + idp.ChangeAppleScopes([]string{"name", "email"}), + idp.ChangeAppleOptions(idp.OptionChanges{ + IsCreationAllowed: &t, + IsLinkingAllowed: &t, + IsAutoCreation: &t, + IsAutoUpdate: &t, + }), + }, + ) + return event + }(), + ), + ), + ), + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "org1", + id: "id1", + provider: AppleProvider{ + ClientID: "clientID2", + TeamID: "teamID2", + KeyID: "keyID2", + PrivateKey: []byte("newPrivateKey"), + Scopes: []string{"name", "email"}, + IDPOptions: idp.Options{ + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ResourceOwner: "org1"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idpConfigEncryption: tt.fields.secretCrypto, + } + got, err := c.UpdateOrgAppleProvider(tt.args.ctx, tt.args.resourceOwner, tt.args.id, tt.args.provider) + 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 stringPointer(s string) *string { return &s } diff --git a/internal/domain/idp.go b/internal/domain/idp.go index c2664c1ab..1378b6ac0 100644 --- a/internal/domain/idp.go +++ b/internal/domain/idp.go @@ -36,6 +36,7 @@ const ( IDPTypeGitLab IDPTypeGitLabSelfHosted IDPTypeGoogle + IDPTypeApple ) func (t IDPType) GetCSSClass() string { @@ -50,6 +51,8 @@ func (t IDPType) GetCSSClass() string { return "gitlab" case IDPTypeAzureAD: return "azure" + case IDPTypeApple: + return "apple" case IDPTypeUnspecified, IDPTypeOIDC, IDPTypeJWT, @@ -78,6 +81,8 @@ func (t IDPType) DisplayName() string { return "GitLab" case IDPTypeGoogle: return "Google" + case IDPTypeApple: + return "Apple" case IDPTypeUnspecified, IDPTypeOIDC, IDPTypeJWT, @@ -94,6 +99,12 @@ func (t IDPType) DisplayName() string { } } +// IsSignInButton returns if the button should be displayed with a translated +// "Sign in with {{.DisplayName}}", e.g. "Sign in with Apple" +func (t IDPType) IsSignInButton() bool { + return t == IDPTypeApple +} + type IDPIntentState int32 const ( diff --git a/internal/form/parser.go b/internal/form/parser.go index 4cba7f9ad..abffac10c 100644 --- a/internal/form/parser.go +++ b/internal/form/parser.go @@ -3,9 +3,9 @@ package form import ( "net/http" - "github.com/zitadel/zitadel/internal/errors" - "github.com/gorilla/schema" + + "github.com/zitadel/zitadel/internal/errors" ) type Parser struct { diff --git a/internal/idp/providers/apple/apple.go b/internal/idp/providers/apple/apple.go new file mode 100644 index 000000000..9463cb61b --- /dev/null +++ b/internal/idp/providers/apple/apple.go @@ -0,0 +1,68 @@ +package apple + +import ( + "crypto/x509" + "encoding/pem" + "time" + + "github.com/zitadel/oidc/v2/pkg/crypto" + openid "github.com/zitadel/oidc/v2/pkg/oidc" + "gopkg.in/square/go-jose.v2" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +const ( + name = "Apple" + issuer = "https://appleid.apple.com" +) + +var _ idp.Provider = (*Provider)(nil) + +// Provider is the [idp.Provider] implementation for Apple +type Provider struct { + *oidc.Provider +} + +func New(clientID, teamID, keyID, callbackURL string, key []byte, scopes []string, options ...oidc.ProviderOpts) (*Provider, error) { + secret, err := clientSecretFromPrivateKey(key, teamID, clientID, keyID) + if err != nil { + return nil, err + } + options = append(options, oidc.WithResponseMode("form_post")) + rp, err := oidc.New(name, issuer, clientID, secret, callbackURL, scopes, oidc.DefaultMapper, options...) + if err != nil { + return nil, err + } + return &Provider{ + Provider: rp, + }, nil +} + +// clientSecretFromPrivateKey uses the private key to create and sign a JWT, which has to be used as client_secret at Apple. +func clientSecretFromPrivateKey(key []byte, teamID, clientID, keyID string) (string, error) { + block, _ := pem.Decode(key) + b := block.Bytes + pk, err := x509.ParsePKCS8PrivateKey(b) + if err != nil { + return "", err + } + signingKey := jose.SigningKey{ + Algorithm: jose.ES256, + Key: &jose.JSONWebKey{Key: pk, KeyID: keyID}, + } + signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{}) + if err != nil { + return "", err + } + iat := time.Now() + exp := iat.Add(time.Hour) + return crypto.Sign(&openid.JWTTokenRequest{ + Issuer: teamID, + Subject: clientID, + Audience: []string{issuer}, + ExpiresAt: openid.FromTime(exp), + IssuedAt: openid.FromTime(iat), + }, signer) +} diff --git a/internal/idp/providers/apple/apple_test.go b/internal/idp/providers/apple/apple_test.go new file mode 100644 index 000000000..6356cca2e --- /dev/null +++ b/internal/idp/providers/apple/apple_test.go @@ -0,0 +1,69 @@ +package apple + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +const ( + privateKey = `-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgXn/LDURaetCoymSj +fRslBiBwzBSa8ifiyfYGIWNStYGgCgYIKoZIzj0DAQehRANCAATymZXIsGrXnl6b ++80miSiVOCcLnyaYa2uQBQvQwgB7GibXhrzF+D/MRTV4P7P8+Lg1K9Khkjc59eNK +4RrQP4g7 +-----END PRIVATE KEY----- +` +) + +func TestProvider_BeginAuth(t *testing.T) { + type fields struct { + clientID string + teamID string + keyID string + privateKey []byte + redirectURI string + scopes []string + } + tests := []struct { + name string + fields fields + want idp.Session + }{ + { + name: "successful auth", + fields: fields{ + clientID: "clientID", + teamID: "teamID", + keyID: "keyID", + privateKey: []byte(privateKey), + redirectURI: "redirectURI", + scopes: []string{"openid"}, + }, + want: &Session{ + Session: &oidc.Session{ + AuthURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + r := require.New(t) + + provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes) + r.NoError(err) + + session, err := provider.BeginAuth(context.Background(), "testState") + r.NoError(err) + + a.Equal(tt.want.GetAuthURL(), session.GetAuthURL()) + }) + } +} diff --git a/internal/idp/providers/apple/session.go b/internal/idp/providers/apple/session.go new file mode 100644 index 000000000..ae629f957 --- /dev/null +++ b/internal/idp/providers/apple/session.go @@ -0,0 +1,64 @@ +package apple + +import ( + "context" + "encoding/json" + + openid "github.com/zitadel/oidc/v2/pkg/oidc" + + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +// Session extends the [oidc.Session] with the formValues returned from the callback. +// This enables to parse the user (name and email), which Apple only returns as form params on registration +type Session struct { + *oidc.Session + UserFormValue string +} + +type userFormValue struct { + Name userNamesFormValue `json:"name,omitempty" schema:"name"` +} + +type userNamesFormValue struct { + FirstName string `json:"firstName,omitempty" schema:"firstName"` + LastName string `json:"lastName,omitempty" schema:"lastName"` +} + +// FetchUser implements the [idp.Session] interface. +// It will execute an OIDC code exchange if needed to retrieve the tokens, +// extract the information from the id_token and if available also from the `user` form value. +// The information will be mapped into an [idp.User]. +func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { + if s.Tokens == nil { + if err = s.Authorize(ctx); err != nil { + return nil, err + } + } + info := s.Tokens.IDTokenClaims.GetUserInfo() + userName := userFormValue{} + if s.UserFormValue != "" { + if err = json.Unmarshal([]byte(s.UserFormValue), &userName); err != nil { + return nil, err + } + } + + return NewUser(info, userName.Name), nil +} + +func NewUser(info *openid.UserInfo, names userNamesFormValue) *User { + user := oidc.NewUser(info) + user.GivenName = names.FirstName + user.FamilyName = names.LastName + return &User{User: user} +} + +// User extends the [oidc.User] by returning the email as preferred_username, since Apple does not return the latter. +type User struct { + *oidc.User +} + +func (u *User) GetPreferredUsername() string { + return u.Email +} diff --git a/internal/idp/providers/apple/session_test.go b/internal/idp/providers/apple/session_test.go new file mode 100644 index 000000000..207f21981 --- /dev/null +++ b/internal/idp/providers/apple/session_test.go @@ -0,0 +1,217 @@ +package apple + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + openid "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" +) + +func TestSession_FetchUser(t *testing.T) { + type fields struct { + clientID string + teamID string + keyID string + privateKey []byte + redirectURI string + scopes []string + httpMock func() + authURL string + code string + tokens *openid.Tokens[*openid.IDTokenClaims] + userFormValue string + } + type want struct { + err error + id string + firstName string + lastName string + displayName string + nickName string + preferredUsername string + email string + isEmailVerified bool + phone string + isPhoneVerified bool + preferredLanguage language.Tag + avatarURL string + profile string + nonceSupported bool + isPrivateEmail bool + } + tests := []struct { + name string + fields fields + want want + }{ + { + name: "unauthenticated session, error", + fields: fields{ + clientID: "clientID", + teamID: "teamID", + keyID: "keyID", + privateKey: []byte(privateKey), + redirectURI: "redirectURI", + scopes: []string{"openid"}, + httpMock: func() {}, + authURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState", + tokens: nil, + }, + want: want{ + err: oidc.ErrCodeMissing, + }, + }, + { + name: "no user param", + fields: fields{ + clientID: "clientID", + teamID: "teamID", + keyID: "keyID", + privateKey: []byte(privateKey), + redirectURI: "redirectURI", + scopes: []string{"openid"}, + httpMock: func() {}, + authURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState", + tokens: &openid.Tokens[*openid.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: openid.BearerToken, + }, + IDTokenClaims: id_token(), + }, + userFormValue: "", + }, + want: want{ + id: "sub", + firstName: "", + lastName: "", + displayName: "", + nickName: "", + preferredUsername: "email", + email: "email", + isEmailVerified: true, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.Und, + avatarURL: "", + profile: "", + nonceSupported: true, + isPrivateEmail: true, + }, + }, + { + name: "with user param", + fields: fields{ + clientID: "clientID", + teamID: "teamID", + keyID: "keyID", + privateKey: []byte(privateKey), + redirectURI: "redirectURI", + scopes: []string{"openid"}, + httpMock: func() {}, + authURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState", + tokens: &openid.Tokens[*openid.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + TokenType: openid.BearerToken, + }, + IDTokenClaims: id_token(), + }, + userFormValue: `{"name": {"firstName": "firstName", "lastName": "lastName"}}`, + }, + want: want{ + id: "sub", + firstName: "firstName", + lastName: "lastName", + displayName: "", + nickName: "", + preferredUsername: "email", + email: "email", + isEmailVerified: true, + phone: "", + isPhoneVerified: false, + preferredLanguage: language.Und, + avatarURL: "", + profile: "", + nonceSupported: true, + isPrivateEmail: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + tt.fields.httpMock() + a := assert.New(t) + + // call the real discovery endpoint + gock.New(issuer).Get(openid.DiscoveryEndpoint).EnableNetworking() + provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes) + require.NoError(t, err) + + session := &Session{ + Session: &oidc.Session{ + Provider: provider.Provider, + AuthURL: tt.fields.authURL, + Code: tt.fields.code, + Tokens: tt.fields.tokens, + }, + UserFormValue: tt.fields.userFormValue, + } + + user, err := session.FetchUser(context.Background()) + if tt.want.err != nil && !errors.Is(err, tt.want.err) { + a.Fail("invalid error", "expected %v, got %v", tt.want.err, err) + } + if tt.want.err == nil { + a.NoError(err) + a.Equal(tt.want.id, user.GetID()) + a.Equal(tt.want.firstName, user.GetFirstName()) + a.Equal(tt.want.lastName, user.GetLastName()) + a.Equal(tt.want.displayName, user.GetDisplayName()) + a.Equal(tt.want.nickName, user.GetNickname()) + a.Equal(tt.want.preferredUsername, user.GetPreferredUsername()) + a.Equal(domain.EmailAddress(tt.want.email), user.GetEmail()) + a.Equal(tt.want.isEmailVerified, user.IsEmailVerified()) + a.Equal(domain.PhoneNumber(tt.want.phone), user.GetPhone()) + a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified()) + a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage()) + a.Equal(tt.want.avatarURL, user.GetAvatarURL()) + a.Equal(tt.want.profile, user.GetProfile()) + } + }) + } +} + +func id_token() *openid.IDTokenClaims { + return &openid.IDTokenClaims{ + TokenClaims: openid.TokenClaims{ + Issuer: issuer, + Subject: "sub", + Audience: []string{"clientID"}, + Expiration: openid.FromTime(time.Now().Add(1 * time.Hour)), + IssuedAt: openid.FromTime(time.Now().Add(-1 * time.Second)), + AuthTime: openid.FromTime(time.Now().Add(-1 * time.Second)), + Nonce: "nonce", + ClientID: "clientID", + }, + UserInfoEmail: openid.UserInfoEmail{ + Email: "email", + EmailVerified: true, + }, + Claims: map[string]any{ + "nonce_supported": true, + "is_private_email": true, + }, + } +} diff --git a/internal/idp/providers/oidc/oidc.go b/internal/idp/providers/oidc/oidc.go index dbd0c76f9..aab525548 100644 --- a/internal/idp/providers/oidc/oidc.go +++ b/internal/idp/providers/oidc/oidc.go @@ -77,6 +77,14 @@ func WithSelectAccount() ProviderOpts { } } +// WithResponseMode sets the `response_mode` params in the auth request +func WithResponseMode(mode oidc.ResponseMode) ProviderOpts { + return func(p *Provider) { + paramOpt := rp.WithResponseModeURLParam(mode) + p.authOptions = append(p.authOptions, rp.AuthURLOpt(paramOpt)) + } +} + type UserInfoMapper func(info *oidc.UserInfo) idp.User var DefaultMapper UserInfoMapper = func(info *oidc.UserInfo) idp.User { diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go index 67b38cd1a..215c24dab 100644 --- a/internal/idp/providers/oidc/session.go +++ b/internal/idp/providers/oidc/session.go @@ -34,7 +34,7 @@ func (s *Session) GetAuthURL() string { // call the userinfo endpoint and map the received information into an [idp.User]. func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { if s.Tokens == nil { - if err = s.authorize(ctx); err != nil { + if err = s.Authorize(ctx); err != nil { return nil, err } } @@ -54,7 +54,7 @@ func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { return u, nil } -func (s *Session) authorize(ctx context.Context) (err error) { +func (s *Session) Authorize(ctx context.Context) (err error) { if s.Code == "" { return ErrCodeMissing } diff --git a/internal/query/idp_template.go b/internal/query/idp_template.go index a19d6b512..148fad49f 100644 --- a/internal/query/idp_template.go +++ b/internal/query/idp_template.go @@ -43,6 +43,7 @@ type IDPTemplate struct { *GitLabSelfHostedIDPTemplate *GoogleIDPTemplate *LDAPIDPTemplate + *AppleIDPTemplate } type IDPTemplates struct { @@ -140,6 +141,15 @@ type LDAPIDPTemplate struct { idp.LDAPAttributes } +type AppleIDPTemplate struct { + IDPID string + ClientID string + TeamID string + KeyID string + PrivateKey *crypto.CryptoValue + Scopes database.StringArray +} + var ( idpTemplateTable = table{ name: projection.IDPTemplateTable, @@ -605,6 +615,41 @@ var ( } ) +var ( + appleIdpTemplateTable = table{ + name: projection.IDPTemplateAppleTable, + instanceIDCol: projection.AppleInstanceIDCol, + } + AppleIDCol = Column{ + name: projection.AppleIDCol, + table: appleIdpTemplateTable, + } + AppleInstanceIDCol = Column{ + name: projection.AppleInstanceIDCol, + table: appleIdpTemplateTable, + } + AppleClientIDCol = Column{ + name: projection.AppleClientIDCol, + table: appleIdpTemplateTable, + } + AppleTeamIDCol = Column{ + name: projection.AppleTeamIDCol, + table: appleIdpTemplateTable, + } + AppleKeyIDCol = Column{ + name: projection.AppleKeyIDCol, + table: appleIdpTemplateTable, + } + ApplePrivateKeyCol = Column{ + name: projection.ApplePrivateKeyCol, + table: appleIdpTemplateTable, + } + AppleScopesCol = Column{ + name: projection.AppleScopesCol, + table: appleIdpTemplateTable, + } +) + // IDPTemplateByID searches for the requested id func (q *Queries) IDPTemplateByID(ctx context.Context, shouldTriggerBulk bool, id string, withOwnerRemoved bool, queries ...SearchQuery) (template *IDPTemplate, err error) { ctx, span := tracing.NewSpan(ctx) @@ -799,6 +844,13 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se LDAPPreferredLanguageAttributeCol.identifier(), LDAPAvatarURLAttributeCol.identifier(), LDAPProfileAttributeCol.identifier(), + // apple + AppleIDCol.identifier(), + AppleClientIDCol.identifier(), + AppleTeamIDCol.identifier(), + AppleKeyIDCol.identifier(), + ApplePrivateKeyCol.identifier(), + AppleScopesCol.identifier(), ).From(idpTemplateTable.identifier()). LeftJoin(join(OAuthIDCol, IDPTemplateIDCol)). LeftJoin(join(OIDCIDCol, IDPTemplateIDCol)). @@ -809,7 +861,8 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se LeftJoin(join(GitLabIDCol, IDPTemplateIDCol)). LeftJoin(join(GitLabSelfHostedIDCol, IDPTemplateIDCol)). LeftJoin(join(GoogleIDCol, IDPTemplateIDCol)). - LeftJoin(join(LDAPIDCol, IDPTemplateIDCol) + db.Timetravel(call.Took(ctx))). + LeftJoin(join(LDAPIDCol, IDPTemplateIDCol)). + LeftJoin(join(AppleIDCol, IDPTemplateIDCol) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*IDPTemplate, error) { idpTemplate := new(IDPTemplate) @@ -898,6 +951,13 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se ldapAvatarURLAttribute := sql.NullString{} ldapProfileAttribute := sql.NullString{} + appleID := sql.NullString{} + appleClientID := sql.NullString{} + appleTeamID := sql.NullString{} + appleKeyID := sql.NullString{} + applePrivateKey := new(crypto.CryptoValue) + appleScopes := database.StringArray{} + err := row.Scan( &idpTemplate.ID, &idpTemplate.ResourceOwner, @@ -994,6 +1054,13 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se &ldapPreferredLanguageAttribute, &ldapAvatarURLAttribute, &ldapProfileAttribute, + // apple + &appleID, + &appleClientID, + &appleTeamID, + &appleKeyID, + &applePrivateKey, + &appleScopes, ) if err != nil { if errs.Is(err, sql.ErrNoRows) { @@ -1118,6 +1185,16 @@ func prepareIDPTemplateByIDQuery(ctx context.Context, db prepareDatabase) (sq.Se }, } } + if appleID.Valid { + idpTemplate.AppleIDPTemplate = &AppleIDPTemplate{ + IDPID: appleID.String, + ClientID: appleClientID.String, + TeamID: appleTeamID.String, + KeyID: appleKeyID.String, + PrivateKey: applePrivateKey, + Scopes: appleScopes, + } + } return idpTemplate, nil } @@ -1220,6 +1297,14 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec LDAPPreferredLanguageAttributeCol.identifier(), LDAPAvatarURLAttributeCol.identifier(), LDAPProfileAttributeCol.identifier(), + // apple + AppleIDCol.identifier(), + AppleClientIDCol.identifier(), + AppleTeamIDCol.identifier(), + AppleKeyIDCol.identifier(), + ApplePrivateKeyCol.identifier(), + AppleScopesCol.identifier(), + // count countColumn.identifier(), ).From(idpTemplateTable.identifier()). LeftJoin(join(OAuthIDCol, IDPTemplateIDCol)). @@ -1231,7 +1316,8 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec LeftJoin(join(GitLabIDCol, IDPTemplateIDCol)). LeftJoin(join(GitLabSelfHostedIDCol, IDPTemplateIDCol)). LeftJoin(join(GoogleIDCol, IDPTemplateIDCol)). - LeftJoin(join(LDAPIDCol, IDPTemplateIDCol) + db.Timetravel(call.Took(ctx))). + LeftJoin(join(LDAPIDCol, IDPTemplateIDCol)). + LeftJoin(join(AppleIDCol, IDPTemplateIDCol) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*IDPTemplates, error) { templates := make([]*IDPTemplate, 0) @@ -1323,6 +1409,13 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec ldapAvatarURLAttribute := sql.NullString{} ldapProfileAttribute := sql.NullString{} + appleID := sql.NullString{} + appleClientID := sql.NullString{} + appleTeamID := sql.NullString{} + appleKeyID := sql.NullString{} + applePrivateKey := new(crypto.CryptoValue) + appleScopes := database.StringArray{} + err := rows.Scan( &idpTemplate.ID, &idpTemplate.ResourceOwner, @@ -1419,6 +1512,13 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec &ldapPreferredLanguageAttribute, &ldapAvatarURLAttribute, &ldapProfileAttribute, + // apple + &appleID, + &appleClientID, + &appleTeamID, + &appleKeyID, + &applePrivateKey, + &appleScopes, &count, ) @@ -1542,6 +1642,16 @@ func prepareIDPTemplatesQuery(ctx context.Context, db prepareDatabase) (sq.Selec }, } } + if appleID.Valid { + idpTemplate.AppleIDPTemplate = &AppleIDPTemplate{ + IDPID: appleID.String, + ClientID: appleClientID.String, + TeamID: appleTeamID.String, + KeyID: appleKeyID.String, + PrivateKey: applePrivateKey, + Scopes: appleScopes, + } + } templates = append(templates, idpTemplate) } diff --git a/internal/query/idp_template_test.go b/internal/query/idp_template_test.go index 34c3ad372..e5c27565d 100644 --- a/internal/query/idp_template_test.go +++ b/internal/query/idp_template_test.go @@ -110,7 +110,14 @@ var ( ` projections.idp_templates5_ldap2.phone_verified_attribute,` + ` projections.idp_templates5_ldap2.preferred_language_attribute,` + ` projections.idp_templates5_ldap2.avatar_url_attribute,` + - ` projections.idp_templates5_ldap2.profile_attribute` + + ` projections.idp_templates5_ldap2.profile_attribute,` + + // apple + ` projections.idp_templates5_apple.idp_id,` + + ` projections.idp_templates5_apple.client_id,` + + ` projections.idp_templates5_apple.team_id,` + + ` projections.idp_templates5_apple.key_id,` + + ` projections.idp_templates5_apple.private_key,` + + ` projections.idp_templates5_apple.scopes` + ` FROM projections.idp_templates5` + ` LEFT JOIN projections.idp_templates5_oauth2 ON projections.idp_templates5.id = projections.idp_templates5_oauth2.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_oauth2.instance_id` + ` LEFT JOIN projections.idp_templates5_oidc ON projections.idp_templates5.id = projections.idp_templates5_oidc.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_oidc.instance_id` + @@ -122,6 +129,7 @@ var ( ` LEFT JOIN projections.idp_templates5_gitlab_self_hosted ON projections.idp_templates5.id = projections.idp_templates5_gitlab_self_hosted.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_gitlab_self_hosted.instance_id` + ` LEFT JOIN projections.idp_templates5_google ON projections.idp_templates5.id = projections.idp_templates5_google.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_google.instance_id` + ` LEFT JOIN projections.idp_templates5_ldap2 ON projections.idp_templates5.id = projections.idp_templates5_ldap2.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_ldap2.instance_id` + + ` LEFT JOIN projections.idp_templates5_apple ON projections.idp_templates5.id = projections.idp_templates5_apple.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_apple.instance_id` + ` AS OF SYSTEM TIME '-1 ms'` idpTemplateCols = []string{ "id", @@ -219,6 +227,13 @@ var ( "preferred_language_attribute", "avatar_url_attribute", "profile_attribute", + // apple config + "idp_id", + "client_id", + "team_id", + "key_id", + "private_key", + "scopes", } idpTemplatesQuery = `SELECT projections.idp_templates5.id,` + ` projections.idp_templates5.resource_owner,` + @@ -315,6 +330,13 @@ var ( ` projections.idp_templates5_ldap2.preferred_language_attribute,` + ` projections.idp_templates5_ldap2.avatar_url_attribute,` + ` projections.idp_templates5_ldap2.profile_attribute,` + + // apple + ` projections.idp_templates5_apple.idp_id,` + + ` projections.idp_templates5_apple.client_id,` + + ` projections.idp_templates5_apple.team_id,` + + ` projections.idp_templates5_apple.key_id,` + + ` projections.idp_templates5_apple.private_key,` + + ` projections.idp_templates5_apple.scopes,` + ` COUNT(*) OVER ()` + ` FROM projections.idp_templates5` + ` LEFT JOIN projections.idp_templates5_oauth2 ON projections.idp_templates5.id = projections.idp_templates5_oauth2.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_oauth2.instance_id` + @@ -327,6 +349,7 @@ var ( ` LEFT JOIN projections.idp_templates5_gitlab_self_hosted ON projections.idp_templates5.id = projections.idp_templates5_gitlab_self_hosted.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_gitlab_self_hosted.instance_id` + ` LEFT JOIN projections.idp_templates5_google ON projections.idp_templates5.id = projections.idp_templates5_google.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_google.instance_id` + ` LEFT JOIN projections.idp_templates5_ldap2 ON projections.idp_templates5.id = projections.idp_templates5_ldap2.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_ldap2.instance_id` + + ` LEFT JOIN projections.idp_templates5_apple ON projections.idp_templates5.id = projections.idp_templates5_apple.idp_id AND projections.idp_templates5.instance_id = projections.idp_templates5_apple.instance_id` + ` AS OF SYSTEM TIME '-1 ms'` idpTemplatesCols = []string{ "id", @@ -424,6 +447,13 @@ var ( "preferred_language_attribute", "avatar_url_attribute", "profile_attribute", + // apple config + "idp_id", + "client_id", + "team_id", + "key_id", + "private_key", + "scopes", "count", } ) @@ -560,6 +590,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -692,6 +729,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -822,6 +866,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -951,6 +1002,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -1079,6 +1137,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -1207,6 +1272,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -1336,6 +1408,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -1464,6 +1543,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { "lang", "avatar", "profile", + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -1509,6 +1595,143 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { }, }, }, + { + name: "prepareIDPTemplateByIDQuery apple idp", + prepare: prepareIDPTemplateByIDQuery, + want: want{ + sqlExpectations: mockQuery( + regexp.QuoteMeta(idpTemplateQuery), + idpTemplateCols, + []driver.Value{ + "idp-id", + "ro", + testNow, + testNow, + uint64(20211109), + domain.IDPConfigStateActive, + "idp-name", + domain.IDPTypeApple, + domain.IdentityProviderTypeOrg, + true, + true, + true, + true, + // oauth + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + // oidc + nil, + nil, + nil, + nil, + nil, + nil, + // jwt + nil, + nil, + nil, + nil, + nil, + // azure + nil, + nil, + nil, + nil, + nil, + nil, + // github + nil, + nil, + nil, + nil, + // github enterprise + nil, + nil, + nil, + nil, + nil, + nil, + nil, + // gitlab + nil, + nil, + nil, + nil, + // gitlab self hosted + nil, + nil, + nil, + nil, + nil, + // google + nil, + nil, + nil, + nil, + // ldap config + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + // apple + "idp-id", + "client_id", + "team_id", + "key_id", + nil, + database.StringArray{"profile"}, + }, + ), + }, + object: &IDPTemplate{ + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + ResourceOwner: "ro", + ID: "idp-id", + State: domain.IDPStateActive, + Name: "idp-name", + Type: domain.IDPTypeApple, + OwnerType: domain.IdentityProviderTypeOrg, + IsCreationAllowed: true, + IsLinkingAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AppleIDPTemplate: &AppleIDPTemplate{ + IDPID: "idp-id", + ClientID: "client_id", + TeamID: "team_id", + KeyID: "key_id", + PrivateKey: nil, + Scopes: []string{"profile"}, + }, + }, + }, { name: "prepareIDPTemplateByIDQuery no config", prepare: prepareIDPTemplateByIDQuery, @@ -1612,6 +1835,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, ), }, @@ -1770,6 +2000,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { "lang", "avatar", "profile", + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, }, ), @@ -1927,6 +2164,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, }, ), @@ -2058,6 +2302,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { "lang", "avatar", "profile", + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, { "idp-id-google", @@ -2155,6 +2406,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, { "idp-id-oauth", @@ -2252,6 +2510,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, { "idp-id-oidc", @@ -2349,6 +2614,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, { "idp-id-jwt", @@ -2446,6 +2718,13 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + // apple + nil, + nil, + nil, + nil, + nil, + nil, }, }, ), diff --git a/internal/query/projection/idp_template.go b/internal/query/projection/idp_template.go index 14a50c6a1..98da2db46 100644 --- a/internal/query/projection/idp_template.go +++ b/internal/query/projection/idp_template.go @@ -28,6 +28,7 @@ const ( IDPTemplateGitLabSelfHostedTable = IDPTemplateTable + "_" + IDPTemplateGitLabSelfHostedSuffix IDPTemplateGoogleTable = IDPTemplateTable + "_" + IDPTemplateGoogleSuffix IDPTemplateLDAPTable = IDPTemplateTable + "_" + IDPTemplateLDAPSuffix + IDPTemplateAppleTable = IDPTemplateTable + "_" + IDPTemplateAppleSuffix IDPTemplateOAuthSuffix = "oauth2" IDPTemplateOIDCSuffix = "oidc" @@ -39,6 +40,7 @@ const ( IDPTemplateGitLabSelfHostedSuffix = "gitlab_self_hosted" IDPTemplateGoogleSuffix = "google" IDPTemplateLDAPSuffix = "ldap2" + IDPTemplateAppleSuffix = "apple" IDPTemplateIDCol = "id" IDPTemplateCreationDateCol = "creation_date" @@ -147,6 +149,14 @@ const ( LDAPPreferredLanguageAttributeCol = "preferred_language_attribute" LDAPAvatarURLAttributeCol = "avatar_url_attribute" LDAPProfileAttributeCol = "profile_attribute" + + AppleIDCol = "idp_id" + AppleInstanceIDCol = "instance_id" + AppleClientIDCol = "client_id" + AppleTeamIDCol = "team_id" + AppleKeyIDCol = "key_id" + ApplePrivateKeyCol = "private_key" + AppleScopesCol = "scopes" ) type idpTemplateProjection struct { @@ -321,6 +331,19 @@ func newIDPTemplateProjection(ctx context.Context, config crdb.StatementHandlerC IDPTemplateLDAPSuffix, crdb.WithForeignKey(crdb.NewForeignKeyOfPublicKeys()), ), + crdb.NewSuffixedTable([]*crdb.Column{ + crdb.NewColumn(AppleIDCol, crdb.ColumnTypeText), + crdb.NewColumn(AppleInstanceIDCol, crdb.ColumnTypeText), + crdb.NewColumn(AppleClientIDCol, crdb.ColumnTypeText), + crdb.NewColumn(AppleTeamIDCol, crdb.ColumnTypeText), + crdb.NewColumn(AppleKeyIDCol, crdb.ColumnTypeText), + crdb.NewColumn(ApplePrivateKeyCol, crdb.ColumnTypeJSONB), + crdb.NewColumn(AppleScopesCol, crdb.ColumnTypeTextArray, crdb.Nullable()), + }, + crdb.NewPrimaryKey(AppleInstanceIDCol, AppleIDCol), + IDPTemplateAppleSuffix, + crdb.WithForeignKey(crdb.NewForeignKeyOfPublicKeys()), + ), ) p.StatementHandler = crdb.NewStatementHandler(ctx, config) return p @@ -443,6 +466,14 @@ func (p *idpTemplateProjection) reducers() []handler.AggregateReducer { Event: instance.LDAPIDPChangedEventType, Reduce: p.reduceLDAPIDPChanged, }, + { + Event: instance.AppleIDPAddedEventType, + Reduce: p.reduceAppleIDPAdded, + }, + { + Event: instance.AppleIDPChangedEventType, + Reduce: p.reduceAppleIDPChanged, + }, { Event: instance.IDPConfigRemovedEventType, Reduce: p.reduceIDPConfigRemoved, @@ -572,6 +603,14 @@ func (p *idpTemplateProjection) reducers() []handler.AggregateReducer { Event: org.LDAPIDPChangedEventType, Reduce: p.reduceLDAPIDPChanged, }, + { + Event: org.AppleIDPAddedEventType, + Reduce: p.reduceAppleIDPAdded, + }, + { + Event: org.AppleIDPChangedEventType, + Reduce: p.reduceAppleIDPChanged, + }, { Event: org.IDPConfigRemovedEventType, Reduce: p.reduceIDPConfigRemoved, @@ -1858,6 +1897,97 @@ func (p *idpTemplateProjection) reduceLDAPIDPChanged(event eventstore.Event) (*h ops..., ), nil } + +func (p *idpTemplateProjection) reduceAppleIDPAdded(event eventstore.Event) (*handler.Statement, error) { + var idpEvent idp.AppleIDPAddedEvent + var idpOwnerType domain.IdentityProviderType + switch e := event.(type) { + case *org.AppleIDPAddedEvent: + idpEvent = e.AppleIDPAddedEvent + idpOwnerType = domain.IdentityProviderTypeOrg + case *instance.AppleIDPAddedEvent: + idpEvent = e.AppleIDPAddedEvent + idpOwnerType = domain.IdentityProviderTypeSystem + default: + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SFvg3", "reduce.wrong.event.type %v", []eventstore.EventType{org.AppleIDPAddedEventType /*, instance.AppleIDPAddedEventType*/}) + } + + return crdb.NewMultiStatement( + &idpEvent, + crdb.AddCreateStatement( + []handler.Column{ + handler.NewCol(IDPTemplateIDCol, idpEvent.ID), + handler.NewCol(IDPTemplateCreationDateCol, idpEvent.CreationDate()), + handler.NewCol(IDPTemplateChangeDateCol, idpEvent.CreationDate()), + handler.NewCol(IDPTemplateSequenceCol, idpEvent.Sequence()), + handler.NewCol(IDPTemplateResourceOwnerCol, idpEvent.Aggregate().ResourceOwner), + handler.NewCol(IDPTemplateInstanceIDCol, idpEvent.Aggregate().InstanceID), + handler.NewCol(IDPTemplateStateCol, domain.IDPStateActive), + handler.NewCol(IDPTemplateNameCol, idpEvent.Name), + handler.NewCol(IDPTemplateOwnerTypeCol, idpOwnerType), + handler.NewCol(IDPTemplateTypeCol, domain.IDPTypeApple), + handler.NewCol(IDPTemplateIsCreationAllowedCol, idpEvent.IsCreationAllowed), + handler.NewCol(IDPTemplateIsLinkingAllowedCol, idpEvent.IsLinkingAllowed), + handler.NewCol(IDPTemplateIsAutoCreationCol, idpEvent.IsAutoCreation), + handler.NewCol(IDPTemplateIsAutoUpdateCol, idpEvent.IsAutoUpdate), + }, + ), + crdb.AddCreateStatement( + []handler.Column{ + handler.NewCol(AppleIDCol, idpEvent.ID), + handler.NewCol(AppleInstanceIDCol, idpEvent.Aggregate().InstanceID), + handler.NewCol(AppleClientIDCol, idpEvent.ClientID), + handler.NewCol(AppleTeamIDCol, idpEvent.TeamID), + handler.NewCol(AppleKeyIDCol, idpEvent.KeyID), + handler.NewCol(ApplePrivateKeyCol, idpEvent.PrivateKey), + handler.NewCol(AppleScopesCol, database.StringArray(idpEvent.Scopes)), + }, + crdb.WithTableSuffix(IDPTemplateAppleSuffix), + ), + ), nil +} + +func (p *idpTemplateProjection) reduceAppleIDPChanged(event eventstore.Event) (*handler.Statement, error) { + var idpEvent idp.AppleIDPChangedEvent + switch e := event.(type) { + case *org.AppleIDPChangedEvent: + idpEvent = e.AppleIDPChangedEvent + case *instance.AppleIDPChangedEvent: + idpEvent = e.AppleIDPChangedEvent + default: + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-GBez3", "reduce.wrong.event.type %v", []eventstore.EventType{org.AppleIDPChangedEventType /*, instance.AppleIDPChangedEventType*/}) + } + + ops := make([]func(eventstore.Event) crdb.Exec, 0, 2) + ops = append(ops, + crdb.AddUpdateStatement( + reduceIDPChangedTemplateColumns(idpEvent.Name, idpEvent.CreationDate(), idpEvent.Sequence(), idpEvent.OptionChanges), + []handler.Condition{ + handler.NewCond(IDPTemplateIDCol, idpEvent.ID), + handler.NewCond(IDPTemplateInstanceIDCol, idpEvent.Aggregate().InstanceID), + }, + ), + ) + appleCols := reduceAppleIDPChangedColumns(idpEvent) + if len(appleCols) > 0 { + ops = append(ops, + crdb.AddUpdateStatement( + appleCols, + []handler.Condition{ + handler.NewCond(AppleIDCol, idpEvent.ID), + handler.NewCond(AppleInstanceIDCol, idpEvent.Aggregate().InstanceID), + }, + crdb.WithTableSuffix(IDPTemplateAppleSuffix), + ), + ) + } + + return crdb.NewMultiStatement( + &idpEvent, + ops..., + ), nil +} + func (p *idpTemplateProjection) reduceIDPConfigRemoved(event eventstore.Event) (*handler.Statement, error) { var idpEvent idpconfig.IDPConfigRemovedEvent switch e := event.(type) { @@ -2176,3 +2306,23 @@ func reduceLDAPIDPChangedColumns(idpEvent idp.LDAPIDPChangedEvent) []handler.Col } return ldapCols } + +func reduceAppleIDPChangedColumns(idpEvent idp.AppleIDPChangedEvent) []handler.Column { + appleCols := make([]handler.Column, 0, 5) + if idpEvent.ClientID != nil { + appleCols = append(appleCols, handler.NewCol(AppleClientIDCol, *idpEvent.ClientID)) + } + if idpEvent.TeamID != nil { + appleCols = append(appleCols, handler.NewCol(AppleTeamIDCol, *idpEvent.TeamID)) + } + if idpEvent.KeyID != nil { + appleCols = append(appleCols, handler.NewCol(AppleKeyIDCol, *idpEvent.KeyID)) + } + if idpEvent.PrivateKey != nil { + appleCols = append(appleCols, handler.NewCol(ApplePrivateKeyCol, *idpEvent.PrivateKey)) + } + if idpEvent.Scopes != nil { + appleCols = append(appleCols, handler.NewCol(AppleScopesCol, database.StringArray(idpEvent.Scopes))) + } + return appleCols +} diff --git a/internal/query/projection/idp_template_test.go b/internal/query/projection/idp_template_test.go index 355020717..fe5c255ea 100644 --- a/internal/query/projection/idp_template_test.go +++ b/internal/query/projection/idp_template_test.go @@ -2440,6 +2440,268 @@ func TestIDPTemplateProjection_reducesLDAP(t *testing.T) { } } +func TestIDPTemplateProjection_reducesApple(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "instance reduceAppleIDPAdded", + args: args{ + event: getEvent(testEvent( + repository.EventType(instance.AppleIDPAddedEventType), + instance.AggregateType, + []byte(`{ + "id": "idp-id", + "clientId": "client_id", + "teamId": "team_id", + "keyId": "key_id", + "privateKey": { + "cryptoType": 0, + "algorithm": "RSA-265", + "keyId": "key-id" + }, + "scopes": ["name"], + "isCreationAllowed": true, + "isLinkingAllowed": true, + "isAutoCreation": true, + "isAutoUpdate": true +}`), + ), instance.AppleIDPAddedEventMapper), + }, + reduce: (&idpTemplateProjection{}).reduceAppleIDPAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: idpTemplateInsertStmt, + expectedArgs: []interface{}{ + "idp-id", + anyArg{}, + anyArg{}, + uint64(15), + "ro-id", + "instance-id", + domain.IDPStateActive, + "", + domain.IdentityProviderTypeSystem, + domain.IDPTypeApple, + true, + true, + true, + true, + }, + }, + { + expectedStmt: "INSERT INTO projections.idp_templates5_apple (idp_id, instance_id, client_id, team_id, key_id, private_key, scopes) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedArgs: []interface{}{ + "idp-id", + "instance-id", + "client_id", + "team_id", + "key_id", + anyArg{}, + database.StringArray{"name"}, + }, + }, + }, + }, + }, + }, + { + name: "org reduceAppleIDPAdded", + args: args{ + event: getEvent(testEvent( + repository.EventType(org.AppleIDPAddedEventType), + org.AggregateType, + []byte(`{ + "id": "idp-id", + "clientId": "client_id", + "teamId": "team_id", + "keyId": "key_id", + "privateKey": { + "cryptoType": 0, + "algorithm": "RSA-265", + "keyId": "key-id" + }, + "scopes": ["name"], + "isCreationAllowed": true, + "isLinkingAllowed": true, + "isAutoCreation": true, + "isAutoUpdate": true +}`), + ), org.AppleIDPAddedEventMapper), + }, + reduce: (&idpTemplateProjection{}).reduceAppleIDPAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: idpTemplateInsertStmt, + expectedArgs: []interface{}{ + "idp-id", + anyArg{}, + anyArg{}, + uint64(15), + "ro-id", + "instance-id", + domain.IDPStateActive, + "", + domain.IdentityProviderTypeOrg, + domain.IDPTypeApple, + true, + true, + true, + true, + }, + }, + { + expectedStmt: "INSERT INTO projections.idp_templates5_apple (idp_id, instance_id, client_id, team_id, key_id, private_key, scopes) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedArgs: []interface{}{ + "idp-id", + "instance-id", + "client_id", + "team_id", + "key_id", + anyArg{}, + database.StringArray{"name"}, + }, + }, + }, + }, + }, + }, + { + name: "instance reduceAppleIDPChanged minimal", + args: args{ + event: getEvent(testEvent( + repository.EventType(instance.AppleIDPChangedEventType), + instance.AggregateType, + []byte(`{ + "id": "idp-id", + "isCreationAllowed": true, + "clientId": "id" + }`), + ), instance.AppleIDPChangedEventMapper), + }, + reduce: (&idpTemplateProjection{}).reduceAppleIDPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: idpTemplateUpdateMinimalStmt, + expectedArgs: []interface{}{ + true, + anyArg{}, + uint64(15), + "idp-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.idp_templates5_apple SET client_id = $1 WHERE (idp_id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "id", + "idp-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceAppleIDPChanged", + args: args{ + event: getEvent(testEvent( + repository.EventType(instance.AppleIDPChangedEventType), + instance.AggregateType, + []byte(`{ + "id": "idp-id", + "name": "name", + "clientId": "client_id", + "teamId": "team_id", + "keyId": "key_id", + "privateKey": { + "cryptoType": 0, + "algorithm": "RSA-265", + "keyId": "key-id" + }, + "scopes": ["name"], + "isCreationAllowed": true, + "isLinkingAllowed": true, + "isAutoCreation": true, + "isAutoUpdate": true + }`), + ), instance.AppleIDPChangedEventMapper), + }, + reduce: (&idpTemplateProjection{}).reduceAppleIDPChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: idpTemplateUpdateStmt, + expectedArgs: []interface{}{ + "name", + true, + true, + true, + true, + anyArg{}, + uint64(15), + "idp-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.idp_templates5_apple SET (client_id, team_id, key_id, private_key, scopes) = ($1, $2, $3, $4, $5) WHERE (idp_id = $6) AND (instance_id = $7)", + expectedArgs: []interface{}{ + "client_id", + "team_id", + "key_id", + anyArg{}, + database.StringArray{"name"}, + "idp-id", + "instance-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if !errors.IsErrorInvalidArgument(err) { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, IDPTemplateTable, tt.want) + }) + } +} + func TestIDPTemplateProjection_reducesOIDC(t *testing.T) { type args struct { event func(t *testing.T) eventstore.Event diff --git a/internal/repository/idp/apple.go b/internal/repository/idp/apple.go new file mode 100644 index 000000000..8c9972c54 --- /dev/null +++ b/internal/repository/idp/apple.go @@ -0,0 +1,164 @@ +package idp + +import ( + "encoding/json" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" +) + +type AppleIDPAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id"` + Name string `json:"name,omitempty"` + ClientID string `json:"clientId"` + TeamID string `json:"teamId"` + KeyID string `json:"keyId"` + PrivateKey *crypto.CryptoValue `json:"privateKey"` + Scopes []string `json:"scopes,omitempty"` + Options +} + +func NewAppleIDPAddedEvent( + base *eventstore.BaseEvent, + id, + name, + clientID, + teamID, + keyID string, + privateKey *crypto.CryptoValue, + scopes []string, + options Options, +) *AppleIDPAddedEvent { + return &AppleIDPAddedEvent{ + BaseEvent: *base, + ID: id, + Name: name, + ClientID: clientID, + TeamID: teamID, + KeyID: keyID, + PrivateKey: privateKey, + Scopes: scopes, + Options: options, + } +} + +func (e *AppleIDPAddedEvent) Data() interface{} { + return e +} + +func (e *AppleIDPAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func AppleIDPAddedEventMapper(event *repository.Event) (eventstore.Event, error) { + e := &AppleIDPAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + + err := json.Unmarshal(event.Data, e) + if err != nil { + return nil, errors.ThrowInternal(err, "IDP-Beqss", "unable to unmarshal event") + } + + return e, nil +} + +type AppleIDPChangedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id"` + Name *string `json:"name,omitempty"` + ClientID *string `json:"clientId,omitempty"` + TeamID *string `json:"teamId,omitempty"` + KeyID *string `json:"keyId,omitempty"` + PrivateKey *crypto.CryptoValue `json:"privateKey,omitempty"` + Scopes []string `json:"scopes,omitempty"` + OptionChanges +} + +func NewAppleIDPChangedEvent( + base *eventstore.BaseEvent, + id string, + changes []AppleIDPChanges, +) (*AppleIDPChangedEvent, error) { + if len(changes) == 0 { + return nil, errors.ThrowPreconditionFailed(nil, "IDP-SF3h2", "Errors.NoChangesFound") + } + changedEvent := &AppleIDPChangedEvent{ + BaseEvent: *base, + ID: id, + } + for _, change := range changes { + change(changedEvent) + } + return changedEvent, nil +} + +type AppleIDPChanges func(*AppleIDPChangedEvent) + +func ChangeAppleName(name string) func(*AppleIDPChangedEvent) { + return func(e *AppleIDPChangedEvent) { + e.Name = &name + } +} + +func ChangeAppleClientID(clientID string) func(*AppleIDPChangedEvent) { + return func(e *AppleIDPChangedEvent) { + e.ClientID = &clientID + } +} + +func ChangeAppleTeamID(teamID string) func(*AppleIDPChangedEvent) { + return func(e *AppleIDPChangedEvent) { + e.TeamID = &teamID + } +} + +func ChangeAppleKeyID(keyID string) func(*AppleIDPChangedEvent) { + return func(e *AppleIDPChangedEvent) { + e.KeyID = &keyID + } +} + +func ChangeApplePrivateKey(privateKey *crypto.CryptoValue) func(*AppleIDPChangedEvent) { + return func(e *AppleIDPChangedEvent) { + e.PrivateKey = privateKey + } +} + +func ChangeAppleScopes(scopes []string) func(*AppleIDPChangedEvent) { + return func(e *AppleIDPChangedEvent) { + e.Scopes = scopes + } +} + +func ChangeAppleOptions(options OptionChanges) func(*AppleIDPChangedEvent) { + return func(e *AppleIDPChangedEvent) { + e.OptionChanges = options + } +} + +func (e *AppleIDPChangedEvent) Data() interface{} { + return e +} + +func (e *AppleIDPChangedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func AppleIDPChangedEventMapper(event *repository.Event) (eventstore.Event, error) { + e := &AppleIDPChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + + err := json.Unmarshal(event.Data, e) + if err != nil { + return nil, errors.ThrowInternal(err, "IDP-NBe1s", "unable to unmarshal event") + } + + return e, nil +} diff --git a/internal/repository/instance/eventstore.go b/internal/repository/instance/eventstore.go index 4161a3696..709abde5e 100644 --- a/internal/repository/instance/eventstore.go +++ b/internal/repository/instance/eventstore.go @@ -92,6 +92,8 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, GoogleIDPChangedEventType, GoogleIDPChangedEventMapper). RegisterFilterEventMapper(AggregateType, LDAPIDPAddedEventType, LDAPIDPAddedEventMapper). RegisterFilterEventMapper(AggregateType, LDAPIDPChangedEventType, LDAPIDPChangedEventMapper). + RegisterFilterEventMapper(AggregateType, AppleIDPAddedEventType, AppleIDPAddedEventMapper). + RegisterFilterEventMapper(AggregateType, AppleIDPChangedEventType, AppleIDPChangedEventMapper). RegisterFilterEventMapper(AggregateType, IDPRemovedEventType, IDPRemovedEventMapper). RegisterFilterEventMapper(AggregateType, LoginPolicyIDPProviderAddedEventType, IdentityProviderAddedEventMapper). RegisterFilterEventMapper(AggregateType, LoginPolicyIDPProviderRemovedEventType, IdentityProviderRemovedEventMapper). diff --git a/internal/repository/instance/idp.go b/internal/repository/instance/idp.go index 7e41c35a1..69b188627 100644 --- a/internal/repository/instance/idp.go +++ b/internal/repository/instance/idp.go @@ -33,6 +33,8 @@ const ( GoogleIDPChangedEventType eventstore.EventType = "instance.idp.google.changed" LDAPIDPAddedEventType eventstore.EventType = "instance.idp.ldap.v2.added" LDAPIDPChangedEventType eventstore.EventType = "instance.idp.ldap.v2.changed" + AppleIDPAddedEventType eventstore.EventType = "instance.idp.apple.added" + AppleIDPChangedEventType eventstore.EventType = "instance.idp.apple.changed" IDPRemovedEventType eventstore.EventType = "instance.idp.removed" ) @@ -920,6 +922,86 @@ func LDAPIDPChangedEventMapper(event *repository.Event) (eventstore.Event, error return &LDAPIDPChangedEvent{LDAPIDPChangedEvent: *e.(*idp.LDAPIDPChangedEvent)}, nil } +type AppleIDPAddedEvent struct { + idp.AppleIDPAddedEvent +} + +func NewAppleIDPAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, + name, + clientID, + teamID, + keyID string, + privateKey *crypto.CryptoValue, + scopes []string, + options idp.Options, +) *AppleIDPAddedEvent { + + return &AppleIDPAddedEvent{ + AppleIDPAddedEvent: *idp.NewAppleIDPAddedEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + AppleIDPAddedEventType, + ), + id, + name, + clientID, + teamID, + keyID, + privateKey, + scopes, + options, + ), + } +} + +func AppleIDPAddedEventMapper(event *repository.Event) (eventstore.Event, error) { + e, err := idp.AppleIDPAddedEventMapper(event) + if err != nil { + return nil, err + } + + return &AppleIDPAddedEvent{AppleIDPAddedEvent: *e.(*idp.AppleIDPAddedEvent)}, nil +} + +type AppleIDPChangedEvent struct { + idp.AppleIDPChangedEvent +} + +func NewAppleIDPChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + changes []idp.AppleIDPChanges, +) (*AppleIDPChangedEvent, error) { + + changedEvent, err := idp.NewAppleIDPChangedEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + AppleIDPChangedEventType, + ), + id, + changes, + ) + if err != nil { + return nil, err + } + return &AppleIDPChangedEvent{AppleIDPChangedEvent: *changedEvent}, nil +} + +func AppleIDPChangedEventMapper(event *repository.Event) (eventstore.Event, error) { + e, err := idp.AppleIDPChangedEventMapper(event) + if err != nil { + return nil, err + } + + return &AppleIDPChangedEvent{AppleIDPChangedEvent: *e.(*idp.AppleIDPChangedEvent)}, nil +} + type IDPRemovedEvent struct { idp.RemovedEvent } diff --git a/internal/repository/org/eventstore.go b/internal/repository/org/eventstore.go index 16332e08f..31ad0c655 100644 --- a/internal/repository/org/eventstore.go +++ b/internal/repository/org/eventstore.go @@ -101,6 +101,8 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, GoogleIDPChangedEventType, GoogleIDPChangedEventMapper). RegisterFilterEventMapper(AggregateType, LDAPIDPAddedEventType, LDAPIDPAddedEventMapper). RegisterFilterEventMapper(AggregateType, LDAPIDPChangedEventType, LDAPIDPChangedEventMapper). + RegisterFilterEventMapper(AggregateType, AppleIDPAddedEventType, AppleIDPAddedEventMapper). + RegisterFilterEventMapper(AggregateType, AppleIDPChangedEventType, AppleIDPChangedEventMapper). RegisterFilterEventMapper(AggregateType, IDPRemovedEventType, IDPRemovedEventMapper). RegisterFilterEventMapper(AggregateType, TriggerActionsSetEventType, TriggerActionsSetEventMapper). RegisterFilterEventMapper(AggregateType, TriggerActionsCascadeRemovedEventType, TriggerActionsCascadeRemovedEventMapper). diff --git a/internal/repository/org/idp.go b/internal/repository/org/idp.go index 6c6201db1..d6e85c13f 100644 --- a/internal/repository/org/idp.go +++ b/internal/repository/org/idp.go @@ -33,6 +33,8 @@ const ( GoogleIDPChangedEventType eventstore.EventType = "org.idp.google.changed" LDAPIDPAddedEventType eventstore.EventType = "org.idp.ldap.added" LDAPIDPChangedEventType eventstore.EventType = "org.idp.ldap.changed" + AppleIDPAddedEventType eventstore.EventType = "org.idp.apple.added" + AppleIDPChangedEventType eventstore.EventType = "org.idp.apple.changed" IDPRemovedEventType eventstore.EventType = "org.idp.removed" ) @@ -920,6 +922,86 @@ func LDAPIDPChangedEventMapper(event *repository.Event) (eventstore.Event, error return &LDAPIDPChangedEvent{LDAPIDPChangedEvent: *e.(*idp.LDAPIDPChangedEvent)}, nil } +type AppleIDPAddedEvent struct { + idp.AppleIDPAddedEvent +} + +func NewAppleIDPAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, + name, + clientID, + teamID, + keyID string, + privateKey *crypto.CryptoValue, + scopes []string, + options idp.Options, +) *AppleIDPAddedEvent { + + return &AppleIDPAddedEvent{ + AppleIDPAddedEvent: *idp.NewAppleIDPAddedEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + AppleIDPAddedEventType, + ), + id, + name, + clientID, + teamID, + keyID, + privateKey, + scopes, + options, + ), + } +} + +func AppleIDPAddedEventMapper(event *repository.Event) (eventstore.Event, error) { + e, err := idp.AppleIDPAddedEventMapper(event) + if err != nil { + return nil, err + } + + return &AppleIDPAddedEvent{AppleIDPAddedEvent: *e.(*idp.AppleIDPAddedEvent)}, nil +} + +type AppleIDPChangedEvent struct { + idp.AppleIDPChangedEvent +} + +func NewAppleIDPChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + changes []idp.AppleIDPChanges, +) (*AppleIDPChangedEvent, error) { + + changedEvent, err := idp.NewAppleIDPChangedEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + AppleIDPChangedEventType, + ), + id, + changes, + ) + if err != nil { + return nil, err + } + return &AppleIDPChangedEvent{AppleIDPChangedEvent: *changedEvent}, nil +} + +func AppleIDPChangedEventMapper(event *repository.Event) (eventstore.Event, error) { + e, err := idp.AppleIDPChangedEventMapper(event) + if err != nil { + return nil, err + } + + return &AppleIDPChangedEvent{AppleIDPChangedEvent: *e.(*idp.AppleIDPChangedEvent)}, nil +} + type IDPRemovedEvent struct { idp.RemovedEvent } diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 8296ce2f5..f0c1bf273 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -201,6 +201,10 @@ Errors: InvalidCharacter: 'Само буквено-цифрови знаци, . ' IDP: InvalidSearchQuery: Невалидна заявка за търсене + ClientIDMissing: Липсва ClientID + TeamIDMissing: TeamID липсва + KeyIDMissing: Липсва KeyID + PrivateKeyMissing: Липсва частен ключ LoginPolicy: NotFound: Правилата за влизане не са намерени Invalid: Правилата за влизане са невалидни diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index fdc52331f..0d7ce3539 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -199,6 +199,10 @@ Errors: InvalidCharacter: Nur alphanumerische Zeichen, . und - sind für eine Domäne erlaubt IDP: InvalidSearchQuery: Ungültiger Suchparameter + ClientIDMissing: ClientID fehlt + TeamIDMissing: TeamID fehlt + KeyIDMissing: KeyID fehlt + PrivateKeyMissing: Private Key fehlt LoginPolicy: NotFound: Login Policy konnte nicht gefunden werden Invalid: Login Policy ist ungültig diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index c5c035451..fcda9ec0b 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -199,6 +199,10 @@ Errors: InvalidCharacter: Only alphanumeric characters, . and - are allowed for a domain IDP: InvalidSearchQuery: Invalid search query + ClientIDMissing: ClientID missing + TeamIDMissing: TeamID missing + KeyIDMissing: KeyID missing + PrivateKeyMissing: Private Key missing LoginPolicy: NotFound: Login Policy not found Invalid: Login Policy is invalid diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 175498bc7..f316baa2a 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -199,6 +199,10 @@ Errors: InvalidCharacter: Solo caracteres alfanuméricos, . y - se permiten para un dominio IDP: InvalidSearchQuery: Consulta de búsqueda no válida + ClientIDMissing: Falta ClientID + TeamIDMissing: Falta TeamID + KeyIDMissing: Falta KeyID + PrivateKeyMissing: Falta la clave privada LoginPolicy: NotFound: Política de inicio de sesión no encontrada Invalid: Política de inicio de sesión no es válida diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index e0ee94e5c..fce9e07bf 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -199,6 +199,10 @@ Errors: InvalidCharacter: Seuls les caractères alphanumériques, . et - sont autorisés pour un domaine IDP: InvalidSearchQuery: Paramètre de recherche non valide + ClientIDMissing: ID client manquant + TeamIDMissing: TeamID manquant + KeyIDMissing: ID de clé manquant + PrivateKeyMissing: clé privée manquante LoginPolicy: NotFound: Politique de connexion non trouvée Invalid: La politique de connexion n'est pas valide diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 0f6b4416d..3b0d8b2bd 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -199,6 +199,10 @@ Errors: IDP: InvalidSearchQuery: Parametro di ricerca non valido InvalidCharacter: Per un dominio sono ammessi solo caratteri alfanumerici, . e - + ClientIDMissing: ClientID mancante + TeamIDMissing: TeamID mancante + KeyIDMissing: ID chiave mancante + PrivateKeyMissing: Chiave privata mancante LoginPolicy: NotFound: Impostazioni di accesso non trovati Invalid: Impostazioni di accesso non sono validi diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 23136fddb..974b18acd 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -191,6 +191,10 @@ Errors: InvalidCharacter: ドメインは英数字、'.'、'-'のみ使用可能です。 IDP: InvalidSearchQuery: 無効な検索クエリです + ClientIDMissing: クライアントIDがありません + TeamIDMissing: チームIDがありません + KeyIDMissing: キーIDがありません + PrivateKeyMissing: 秘密キーがありません LoginPolicy: NotFound: ログインポリシーが見つかりません Invalid: 無効なログインポリシーです diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 9b8e8485c..dd691345d 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -199,6 +199,10 @@ Errors: InvalidCharacter: Дозволени се само алфанумерички знаци, . и - се дозволени за домен IDP: InvalidSearchQuery: Невалидно пребарување + ClientID Missing: ClientID недостасува + TeamIDMissing: TeamID недостасува + Клучен ID Недостасува: Недостасува ID на клуч + PrivateKeyMissing: Недостасува приватен клуч LoginPolicy: NotFound: Политиката за најавување не е пронајдена Invalid: Политиката за најавување е невалидна diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index fab78662b..98bd773e7 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -199,6 +199,10 @@ Errors: InvalidCharacter: Tylko znaki alfanumeryczne, . i - są dozwolone dla domeny IDP: InvalidSearchQuery: Nieprawidłowe zapytanie wyszukiwania + ClientIDMissing: Brak ClientID + TeamIDMissing: Brak TeamID + KeyIDMissing: Brak KeyID + PrivateKeyMissing: Brak klucza prywatnego LoginPolicy: NotFound: Polityka logowania nie znaleziona Invalid: Polityka logowania jest nieprawidłowa diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index a2e975449..95f64e43b 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -197,6 +197,10 @@ Errors: InvalidCharacter: Apenas caracteres alfanuméricos, . e - são permitidos para um domínio IDP: InvalidSearchQuery: Consulta de pesquisa inválida + ClientIDMissing: ClientID ausente + TeamIDMissing: TeamID ausente + KeyIDMissing: KeyID ausente + PrivateKeyMissing: Chave privada ausente LoginPolicy: NotFound: Política de login não encontrada Invalid: Política de login é inválida diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index b3d835935..601438224 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -199,6 +199,10 @@ Errors: InvalidCharacter: 只有字母数字字符,.和 - 允许用于域名中 IDP: InvalidSearchQuery: 无效的搜索查询 + ClientIDMissing: 客户端 ID 丢失 + TeamIDMissing: 团队 ID 丢失 + KeyIDMissing: 密钥 ID 丢失 + PrivateKeyMissing: 私钥丢失 LoginPolicy: NotFound: 未找到登录策略 Invalid: 登录策略无效 diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 65a95ba36..99cf4893d 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -1648,6 +1648,42 @@ service AdminService { }; } + // Add a new Apple identity provider on the instance + rpc AddAppleProvider(AddAppleProviderRequest) returns (AddAppleProviderResponse) { + option (google.api.http) = { + post: "/idps/apple" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.idp.write" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Identity Providers"; + summary: "Add Apple Identity Provider"; + description: ""; + }; + } + + // Change an existing Apple identity provider on the instance + rpc UpdateAppleProvider(UpdateAppleProviderRequest) returns (UpdateAppleProviderResponse) { + option (google.api.http) = { + put: "/idps/apple/{id}" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.idp.write" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Identity Providers"; + summary: "Update Apple Identity Provider"; + description: ""; + }; + } + // Remove an identity provider // Will remove all linked providers of this configuration on the users rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse) { @@ -5564,6 +5600,134 @@ message UpdateLDAPProviderResponse { zitadel.v1.ObjectDetails details = 1; } +message AddAppleProviderRequest { + // Apple will be used as default, if no name is provided + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Apple\""; + description: "Apple will be used as default, if no name is provided"; + } + ]; + string client_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"client-id\""; + description: "Client id (App ID or Service ID) provided by Apple"; + } + ]; + string team_id = 3 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"ALT03JV3OS\""; + description: "(10-character) Team ID provided by Apple"; + } + ]; + string key_id = 4 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"OGKDK25KD\""; + description: "(10-character) ID of the private key generated by Apple"; + } + ]; + bytes private_key = 5 [ + (validate.rules).bytes = {min_len: 1, max_len: 5000}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 5000; + example: "\"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1...\""; + description: "Private Key generated by Apple"; + } + ]; + repeated string scopes = 6 [ + (validate.rules).repeated = {max_items: 20, items: {string: {min_len: 1, max_len: 100}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_items: 20, + example: "[\"name\", \"email\"]"; + description: "The scopes requested by ZITADEL during the request to Apple"; + } + ]; + zitadel.idp.v1.Options provider_options = 7; +} + +message AddAppleProviderResponse { + zitadel.v1.ObjectDetails details = 1; + string id = 2; +} + +message UpdateAppleProviderRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string name = 2 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200, + example: "\"Apple\""; + } + ]; + string client_id = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"client-id\""; + description: "Client id (App ID or Service ID) provided by Apple"; + } + ]; + string team_id = 4 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"ALT03JV3OS\""; + description: "(10-character) Team ID provided by Apple"; + } + ]; + string key_id = 5 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"OGKDK25KD\""; + description: "(10-character) ID of the private key generated by Apple"; + } + ]; + bytes private_key = 6 [ + (validate.rules).bytes = {max_len: 5000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 5000, + example: "\"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1...\""; + description: "Private Key generated by Apple"; + } + ]; + repeated string scopes = 7 [ + (validate.rules).repeated = {max_items: 20, items: {string: {min_len: 1, max_len: 100}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_items: 20, + example: "[\"openid\", \"profile\", \"email\"]"; + description: "The scopes requested by ZITADEL during the request to Apple"; + } + ]; + zitadel.idp.v1.Options provider_options = 8; +} + +message UpdateAppleProviderResponse { + zitadel.v1.ObjectDetails details = 1; +} + message DeleteProviderRequest { string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/idp.proto b/proto/zitadel/idp.proto index b8c388bb6..ddf416dd5 100644 --- a/proto/zitadel/idp.proto +++ b/proto/zitadel/idp.proto @@ -266,6 +266,7 @@ enum ProviderType { PROVIDER_TYPE_GITLAB = 8; PROVIDER_TYPE_GITLAB_SELF_HOSTED = 9; PROVIDER_TYPE_GOOGLE = 10; + PROVIDER_TYPE_APPLE = 11; } message ProviderConfig { @@ -281,6 +282,7 @@ message ProviderConfig { GitLabConfig gitlab = 9; GitLabSelfHostedConfig gitlab_self_hosted = 10; AzureADConfig azure_ad = 11; + AppleConfig apple = 12; } } @@ -517,3 +519,30 @@ message AzureADTenant { string tenant_id = 2; } } + +message AppleConfig { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"com.client.id\""; + description: "Client id (App ID or Service ID) provided by Apple"; + } + ]; + string team_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ALT03JV3OS\""; + description: "Team ID provided by Apple"; + } + ]; + string key_id = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"OGKDK25KD\""; + description: "ID of the private key generated by Apple"; + } + ]; + repeated string scopes = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"name\", \"email\"]"; + description: "the scopes requested by ZITADEL during the request to Apple"; + } + ]; +} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index fe8901947..6561ff16c 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -7058,6 +7058,42 @@ service ManagementService { }; } + // Add a new Apple identity provider in the organization + rpc AddAppleProvider(AddAppleProviderRequest) returns (AddAppleProviderResponse) { + option (google.api.http) = { + post: "/idps/apple" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "org.idp.write" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Identity Providers"; + summary: "Add Apple Identity Provider"; + description: ""; + }; + } + + // Change an existing Apple identity provider in the organization + rpc UpdateAppleProvider(UpdateAppleProviderRequest) returns (UpdateAppleProviderResponse) { + option (google.api.http) = { + put: "/idps/apple/{id}" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "org.idp.write" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Identity Providers"; + summary: "Update Apple Identity Provider"; + description: ""; + }; + } + // Remove an identity provider // Will remove all linked providers of this configuration on the users rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse) { @@ -12448,6 +12484,134 @@ message UpdateLDAPProviderResponse { zitadel.v1.ObjectDetails details = 1; } +message AddAppleProviderRequest { + // Apple will be used as default, if no name is provided + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Apple\""; + description: "Apple will be used as default, if no name is provided"; + } + ]; + string client_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"com.client.id\""; + description: "Client id (App ID or Service ID) provided by Apple"; + } + ]; + string team_id = 3 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"ALT03JV3OS\""; + description: "(10-character) Team ID provided by Apple"; + } + ]; + string key_id = 4 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"OGKDK25KD\""; + description: "(10-character) ID of the private key generated by Apple"; + } + ]; + bytes private_key = 5 [ + (validate.rules).bytes = {min_len: 1, max_len: 5000}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 5000; + example: "\"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1...\""; + description: "Private Key generated by Apple"; + } + ]; + repeated string scopes = 6 [ + (validate.rules).repeated = {max_items: 20, items: {string: {min_len: 1, max_len: 100}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_items: 20, + example: "[\"name\", \"email\"]"; + description: "The scopes requested by ZITADEL during the request to Apple"; + } + ]; + zitadel.idp.v1.Options provider_options = 7; +} + +message AddAppleProviderResponse { + zitadel.v1.ObjectDetails details = 1; + string id = 2; +} + +message UpdateAppleProviderRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string name = 2 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200, + example: "\"Apple\""; + } + ]; + string client_id = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200, + example: "\"client-id\""; + description: "Client id (App ID or Service ID) provided by Apple"; + } + ]; + string team_id = 4 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"ALT03JV3OS\""; + description: "(10-character) Team ID provided by Apple"; + } + ]; + string key_id = 5 [ + (validate.rules).string = {len: 10}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 10; + max_length: 10; + example: "\"OGKDK25KD\""; + description: "(10-character) ID of the private key generated by Apple"; + } + ]; + bytes private_key = 6 [ + (validate.rules).bytes = {max_len: 5000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 5000, + example: "\"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1...\""; + description: "Private Key generated by Apple"; + } + ]; + repeated string scopes = 7 [ + (validate.rules).repeated = {max_items: 20, items: {string: {min_len: 1, max_len: 100}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_items: 20, + example: "[\"openid\", \"profile\", \"email\"]"; + description: "The scopes requested by ZITADEL during the request to Apple"; + } + ]; + zitadel.idp.v1.Options provider_options = 8; +} + +message UpdateAppleProviderResponse { + zitadel.v1.ObjectDetails details = 1; +} + message DeleteProviderRequest { string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; }