diff --git a/apps/docs/content/apis/openidoauth/endpoints.mdx b/apps/docs/content/apis/openidoauth/endpoints.mdx index 10dc4b8be..addbfef2a 100644 --- a/apps/docs/content/apis/openidoauth/endpoints.mdx +++ b/apps/docs/content/apis/openidoauth/endpoints.mdx @@ -15,6 +15,13 @@ This would give us `${CUSTOM_DOMAIN}/.well-known/openid-configuration`. **Link to spec.** [OpenID Connect Discovery 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-discovery-1_0.html) +ZITADEL also serves instance-scoped association files for native passkeys (not part of OIDC discovery): + +- `${CUSTOM_DOMAIN}/.well-known/apple-app-site-association` +- `${CUSTOM_DOMAIN}/.well-known/assetlinks.json` + +See [Native app links for passkeys](/guides/integrate/login/oidc/native-app-links). + ## authorization_endpoint `${CUSTOM_DOMAIN}/oauth/v2/authorize` diff --git a/apps/docs/content/concepts/features/passkeys.mdx b/apps/docs/content/concepts/features/passkeys.mdx index 8a95dd0d2..2f314c227 100644 --- a/apps/docs/content/concepts/features/passkeys.mdx +++ b/apps/docs/content/concepts/features/passkeys.mdx @@ -35,5 +35,6 @@ Imagine signing in without passwords! Passkeys, replacing traditional passwords, * Passkey support is still evolving in browsers and platforms. Check compatibility for your target audience. * ZITADEL actively develops its passkey features. Stay updated with documentation and releases. * Passkeys are bound to your domain, thus we recommend configuring a [Custom Domain](/concepts/features/custom-domain) before setting up passkeys. +* Native iOS and Android apps that use platform passkey APIs also need domain association files. Configure them on the OIDC app and let ZITADEL serve the well-known documents — see [Native app links for passkeys](/guides/integrate/login/oidc/native-app-links). Don't hesitate to ask if you have further questions about integrating passkeys in your ZITADEL application! diff --git a/apps/docs/content/guides/integrate/login-ui/passkey.mdx b/apps/docs/content/guides/integrate/login-ui/passkey.mdx index b4e643374..8d9ca5d59 100644 --- a/apps/docs/content/guides/integrate/login-ui/passkey.mdx +++ b/apps/docs/content/guides/integrate/login-ui/passkey.mdx @@ -12,6 +12,11 @@ Passkeys are a replacement for passwords that provide faster, easier, and more s Unlike passwords, passkeys are phishing-resistant and can improve the user experience and security at the same time. Passkeys and there underlying protocols are a standard defined by the [FIDO Standard](https://fidoalliance.org/) . + +This guide covers passkeys in a **custom browser login UI**. +Native iOS/Android apps that use platform passkey APIs also need domain association files served from your issuer — see [Native app links for passkeys](/guides/integrate/login/oidc/native-app-links). + + ## Register Passkey ### Flow diff --git a/apps/docs/content/guides/integrate/login/oidc/index.mdx b/apps/docs/content/guides/integrate/login/oidc/index.mdx index 4d1edceee..58078d3f8 100644 --- a/apps/docs/content/guides/integrate/login/oidc/index.mdx +++ b/apps/docs/content/guides/integrate/login/oidc/index.mdx @@ -21,5 +21,6 @@ import { FileText, Folder, Link as LinkIcon } from 'lucide-react'; } /> } /> } /> + } /> } /> diff --git a/apps/docs/content/guides/integrate/login/oidc/native-app-links.mdx b/apps/docs/content/guides/integrate/login/oidc/native-app-links.mdx new file mode 100644 index 000000000..b2a0645c9 --- /dev/null +++ b/apps/docs/content/guides/integrate/login/oidc/native-app-links.mdx @@ -0,0 +1,135 @@ +--- +title: Native app links for passkeys +description: "Configure iOS and Android association on OIDC apps so ZITADEL can serve apple-app-site-association and assetlinks.json for native passkeys" +sidebar_label: Native App Links +--- + +Native mobile apps that create or use passkeys with platform WebAuthn APIs need the issuer domain to publish OS trust association files. +ZITADEL serves those files from your instance based on iOS and Android settings on OIDC applications. + +## Prerequisites + +- An OIDC **Native** application in your project +- Passkeys enabled in your login policy +- A stable issuer / [custom domain](/concepts/features/custom-domain) (passkeys are domain-bound) +- Platform association configured in the mobile app project (Associated Domains on iOS; Digital Asset Links / App Links on Android) + +## Configure the OIDC application + +Set association fields on each Native OIDC app that should participate in passkey trust. + +You can do this in the [Console](/guides/manage/console/applications-overview#native-app-links) under the app's **Native App Links** section, or via the Application API / Management API OIDC app create and update endpoints (`ios` / `android`). + +### iOS + +| Field | Description | +|-------|-------------| +| **Team ID** | Apple Developer Team ID (exactly 10 alphanumeric characters), for example `ABCDE12345` | +| **Bundle ID** | App bundle identifier (`CFBundleIdentifier`), for example `com.example.app` — do not include the Team ID prefix | + +ZITADEL publishes these as a single App ID in the association file: `{team_id}.{bundle_id}`. + +### Android + +| Field | Description | +|-------|-------------| +| **Package name** | Android `applicationId` / package name from the app manifest | +| **SHA-256 certificate fingerprints** | Signing certificate fingerprints (64 hex characters, optionally colon-separated). Include debug and release fingerprints as needed. | + +### Update semantics + +On update: + +- Omit a field group to leave it unchanged +- Send an empty message / empty values to clear +- Send populated values to replace + +## Well-known endpoints + +ZITADEL aggregates association data from **active** OIDC apps on the instance and serves: + +| Path | Purpose | +|------|---------| +| `${CUSTOM_DOMAIN}/.well-known/apple-app-site-association` | iOS Associated Domains / `webcredentials` | +| `${CUSTOM_DOMAIN}/.well-known/assetlinks.json` | Android Digital Asset Links for `delegate_permission/common.get_login_creds` | + +If no apps are configured, both endpoints still return HTTP `200` with empty collections. + +### Example: apple-app-site-association + +```json +{ + "webcredentials": { + "apps": [ + "ABCDE12345.com.example.one", + "FGHIJ67890.com.example.two" + ] + } +} +``` + +Duplicate iOS App IDs across apps are deduplicated. + +### Example: assetlinks.json + +```json +[ + { + "relation": ["delegate_permission/common.get_login_creds"], + "target": { + "namespace": "android_app", + "package_name": "com.example.one", + "sha256_cert_fingerprints": [ + "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" + ] + } + } +] +``` + +Fingerprints are normalized to colon-separated uppercase hex when served. + +## Caching + +Successful responses include a `Cache-Control` header so intermediate caches may reuse the documents for a short time. + +By default (and in ZITADEL Cloud) caching is allowed for 5 minutes: + +``` +Cache-Control: public, max-age=300 +``` + +Self-hosters can change this with the `ZITADEL_WELLKNOWN_APPLINKSCACHECONTROLMAXAGE` environment variable or in the settings YAML: + +```yaml +WellKnown: + AppLinksCacheControlMaxAge: 5m +``` + +Setting the value to `0` results in `Cache-Control: no-store`. + + +Platform verifiers may cache association files on their side as well. +After you change app link settings, allow time for HTTP caches and OS verification to refresh before expecting passkeys to work with the new values. + + +## Verify + +```bash +curl -i "${CUSTOM_DOMAIN}/.well-known/apple-app-site-association" +curl -i "${CUSTOM_DOMAIN}/.well-known/assetlinks.json" +``` + +Confirm: + +- HTTP status `200` +- `Content-Type: application/json` +- Expected `Cache-Control` header +- JSON contents match your configured apps + +## Related + +- [Passkeys concept](/concepts/features/passkeys) +- [Console applications](/guides/manage/console/applications-overview) +- [Custom domain](/concepts/features/custom-domain) +- [Passkeys in a custom login UI](/guides/integrate/login-ui/passkey) (browser / WebAuthn APIs) diff --git a/apps/docs/content/guides/manage/console/applications-overview.mdx b/apps/docs/content/guides/manage/console/applications-overview.mdx index 41913988e..a01162caf 100644 --- a/apps/docs/content/guides/manage/console/applications-overview.mdx +++ b/apps/docs/content/guides/manage/console/applications-overview.mdx @@ -63,6 +63,15 @@ Native Applications are installed on a thin client, such as a smartphone or desk +#### Native app links + +For native passkeys, configure OS association on the OIDC app under **Native App Links**: + +* **iOS:** Team ID and Bundle ID (published in `/.well-known/apple-app-site-association`) +* **Android:** Package name and SHA-256 certificate fingerprints (published in `/.well-known/assetlinks.json`) + +See [Native app links for passkeys](/guides/integrate/login/oidc/native-app-links) for endpoint details, caching, and verification. + ### User Agent User Agent Applications are executed entirely in the web browser (Client-Side). @@ -219,3 +228,4 @@ Ensure the management of application settings is limited to authorized users onl ## References - [Recommended OIDC Flows](../../integrate/login/oidc/oauth-recommended-flows) +- [Native app links for passkeys](../../integrate/login/oidc/native-app-links) diff --git a/apps/docs/lib/sidebar-data.ts b/apps/docs/lib/sidebar-data.ts index e1478525f..fc06e2619 100644 --- a/apps/docs/lib/sidebar-data.ts +++ b/apps/docs/lib/sidebar-data.ts @@ -269,6 +269,7 @@ export const guidesSidebar: readonly SidebarItem[] = [ "apis/openidoauth/grant-types", "apis/openidoauth/authrequest", "guides/integrate/login/oidc/webkeys", + "guides/integrate/login/oidc/native-app-links", "guides/integrate/token-exchange", ], }, diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 294e63d2a..c37b3a940 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -701,6 +701,11 @@ OIDC: # Lifetime of the token used to notify clients through OIDC back-channel logout. TokenLifetime: 15m # ZITADEL_OIDC_BACKCHANNELLOGOUT_TOKENLIFETIME +WellKnown: + # HTTP Cache-Control max-age for /.well-known/apple-app-site-association and + # /.well-known/assetlinks.json. 0 sets Cache-Control: no-store. + AppLinksCacheControlMaxAge: 5m # ZITADEL_WELLKNOWN_APPLINKSCACHECONTROLMAXAGE + SAML: DefaultLoginURLV2: "/ui/v2/login/login?samlRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: diff --git a/cmd/setup/75.go b/cmd/setup/75.go new file mode 100644 index 000000000..108a8c2c6 --- /dev/null +++ b/cmd/setup/75.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 75.sql + addOIDCAppLinkConfig string +) + +type Apps7OIDCConfigsAddAppLinkConfig struct { + dbClient *database.DB +} + +func (mig *Apps7OIDCConfigsAddAppLinkConfig) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addOIDCAppLinkConfig) + return err +} + +func (mig *Apps7OIDCConfigsAddAppLinkConfig) String() string { + return "75_apps7_oidc_configs_add_app_link_config" +} diff --git a/cmd/setup/75.sql b/cmd/setup/75.sql new file mode 100644 index 000000000..b4fc9d99f --- /dev/null +++ b/cmd/setup/75.sql @@ -0,0 +1,4 @@ +ALTER TABLE IF EXISTS projections.apps7_oidc_configs ADD COLUMN IF NOT EXISTS ios_team_id TEXT; +ALTER TABLE IF EXISTS projections.apps7_oidc_configs ADD COLUMN IF NOT EXISTS ios_bundle_id TEXT; +ALTER TABLE IF EXISTS projections.apps7_oidc_configs ADD COLUMN IF NOT EXISTS android_package_name TEXT; +ALTER TABLE IF EXISTS projections.apps7_oidc_configs ADD COLUMN IF NOT EXISTS android_sha256_cert_fingerprints TEXT[]; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index c8d696864..f0eb6e637 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -194,6 +194,7 @@ type Steps struct { s72AddColumnsToLoginNamesView *AddColumnsToLoginNamesView s73FixUserGrantRoles *FixUserGrantRoles s74Apps7OIDCConfigsAddRegistrationToken *Apps7OIDCConfigsAddRegistrationToken + s75Apps7OIDCConfigsAddAppLinkConfig *Apps7OIDCConfigsAddAppLinkConfig RelationalTables *TransactionalTables } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index cebf085b3..4c9942d81 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -257,6 +257,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s72AddColumnsToLoginNamesView = &AddColumnsToLoginNamesView{dbClient: dbClient} steps.s73FixUserGrantRoles = &FixUserGrantRoles{eventstore: eventstoreClient} steps.s74Apps7OIDCConfigsAddRegistrationToken = &Apps7OIDCConfigsAddRegistrationToken{dbClient: dbClient} + steps.s75Apps7OIDCConfigsAddAppLinkConfig = &Apps7OIDCConfigsAddAppLinkConfig{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) if err != nil { @@ -390,6 +391,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s72AddColumnsToLoginNamesView, steps.s73FixUserGrantRoles, steps.s74Apps7OIDCConfigsAddRegistrationToken, + steps.s75Apps7OIDCConfigsAddAppLinkConfig, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/start/config.go b/cmd/start/config.go index 1ddc9c71b..2591dfb24 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -24,6 +24,7 @@ import ( scim_config "github.com/zitadel/zitadel/internal/api/scim/config" "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/api/well_known" auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing" "github.com/zitadel/zitadel/internal/cache/connector" "github.com/zitadel/zitadel/internal/command" @@ -72,6 +73,7 @@ type Config struct { SCIM scim_config.Config Login login.Config Console console.Config + WellKnown well_known.Config AssetStorage static_config.AssetStorageConfig InternalAuthZ authz.Config SystemAuthZ authz.Config diff --git a/cmd/start/start.go b/cmd/start/start.go index 19e520fec..73c4838df 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -85,6 +85,7 @@ import ( "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/console/path" "github.com/zitadel/zitadel/internal/api/ui/login" + "github.com/zitadel/zitadel/internal/api/well_known" auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing" "github.com/zitadel/zitadel/internal/authz" authz_repo "github.com/zitadel/zitadel/internal/authz/repository" @@ -657,6 +658,9 @@ func startAPIs( } apis.RegisterHandlerOnPrefix(robots_txt.HandlerPrefix, robotsTxtHandler) + // native app link well-known files (instance-scoped) + apis.RegisterHandlerPrefixes(instanceInterceptor.Handler(well_known.NewHandler(queries, config.WellKnown)), well_known.HandlerPrefixes...) + // TODO: Record openapi access logs? openAPIHandler, err := openapi.Start() if err != nil { diff --git a/console/src/app/pages/projects/apps/app-detail/app-detail.component.html b/console/src/app/pages/projects/apps/app-detail/app-detail.component.html index 5a4c35273..28650627b 100644 --- a/console/src/app/pages/projects/apps/app-detail/app-detail.component.html +++ b/console/src/app/pages/projects/apps/app-detail/app-detail.component.html @@ -462,6 +462,102 @@ } + + @if (currentSetting.id === 'native-app-links' && oidcForm && app?.oidcConfig) { + +

{{ 'APP.OIDC.APPLINKSDESC' | translate }}

+
+

{{ 'APP.OIDC.IOSTITLE' | translate }}

+ + {{ 'APP.OIDC.IOSDESC' | translate }} + @if (issuer$ | async; as issuer) { + + } + +
+ + {{ 'APP.OIDC.TEAMID' | translate }} + + + + {{ 'APP.OIDC.BUNDLEID' | translate }} + + +
+ + {{ 'APP.OIDC.TEAMIDDESC' | translate }} {{ 'APP.OIDC.BUNDLEIDDESC' | translate }} + + @if (showIosAppLinkIncompleteWarning()) { + + {{ 'APP.OIDC.IOSAPPLINKINCOMPLETE' | translate }} + + } + +

{{ 'APP.OIDC.ANDROIDTITLE' | translate }}

+ + {{ 'APP.OIDC.ANDROIDDESC' | translate }} + @if (issuer$ | async; as issuer) { + + } + +
+ + {{ 'APP.OIDC.PACKAGENAME' | translate }} + + + + {{ 'APP.OIDC.FINGERPRINTS' | translate }} + + +
+ + {{ 'APP.OIDC.FINGERPRINTSDESC' | translate }} + + @if (showAndroidPackageRequiredWarning()) { + + {{ 'APP.OIDC.ANDROIDPACKAGEREQUIRED' | translate }} + + } + +
+ +
+
+
+ } + @if (currentSetting.id === 'urls') { @if (issuer$ | async; as issuer) { diff --git a/console/src/app/pages/projects/apps/app-detail/app-detail.component.scss b/console/src/app/pages/projects/apps/app-detail/app-detail.component.scss index 2efa5d852..a053f8d94 100644 --- a/console/src/app/pages/projects/apps/app-detail/app-detail.component.scss +++ b/console/src/app/pages/projects/apps/app-detail/app-detail.component.scss @@ -53,6 +53,11 @@ .app-desc { font-size: 14px; margin: 0; + + .app-link-endpoint { + margin-top: 0.5rem; + word-break: break-all; + } } .app-input { @@ -281,6 +286,17 @@ input { max-width: none; } + + textarea.fingerprints-input, + .fingerprints-input { + max-width: none; + width: 100%; + min-height: 12rem; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 0.875rem; + line-height: 1.4; + } } } diff --git a/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts b/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts index 4476a8f61..47ec26508 100644 --- a/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts +++ b/console/src/app/pages/projects/apps/app-detail/app-detail.component.ts @@ -1,7 +1,16 @@ import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; import { Location } from '@angular/common'; import { Component, OnDestroy, OnInit, signal } from '@angular/core'; -import { AbstractControl, FormControl, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { + AbstractControl, + FormControl, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms'; import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; @@ -206,18 +215,25 @@ export class AppDetailComponent implements OnInit, OnDestroy { private router: Router, private breadcrumbService: BreadcrumbService, ) { - this.oidcForm = this.fb.group({ - devMode: [{ value: false, disabled: true }], - skipNativeAppSuccessPage: [{ value: false, disabled: true }], - clientId: [{ value: '', disabled: true }], - responseTypesList: [{ value: [], disabled: true }], - grantTypesList: [{ value: [], disabled: true }], - appType: [{ value: '', disabled: true }], - authMethodType: [{ value: '', disabled: true }], - loginV2: [{ value: false, disabled: true }], - loginV2BaseURL: [{ value: '', disabled: true }], - backChannelLogoutURI: [{ value: '', disabled: true }], - }); + this.oidcForm = this.fb.group( + { + devMode: [{ value: false, disabled: true }], + skipNativeAppSuccessPage: [{ value: false, disabled: true }], + clientId: [{ value: '', disabled: true }], + responseTypesList: [{ value: [], disabled: true }], + grantTypesList: [{ value: [], disabled: true }], + appType: [{ value: '', disabled: true }], + authMethodType: [{ value: '', disabled: true }], + loginV2: [{ value: false, disabled: true }], + loginV2BaseURL: [{ value: '', disabled: true }], + backChannelLogoutURI: [{ value: '', disabled: true }], + iosTeamId: [{ value: '', disabled: true }, [iosTeamIdValidator]], + iosBundleId: [{ value: '', disabled: true }, [Validators.maxLength(200)]], + androidPackageName: [{ value: '', disabled: true }, [Validators.maxLength(200)]], + androidSha256CertFingerprints: [{ value: '', disabled: true }], + }, + { validators: [appLinkConfigValidator()] }, + ); this.oidcTokenForm = this.fb.group({ accessTokenType: [{ value: '', disabled: true }], @@ -375,6 +391,7 @@ export class AppDetailComponent implements OnInit, OnDestroy { this.settingsList = [ { id: 'configuration', i18nKey: 'APP.CONFIGURATION' }, { id: 'token', i18nKey: 'APP.TOKEN' }, + { id: 'native-app-links', i18nKey: 'APP.OIDC.APPLINKSSECTION' }, { id: 'urls', i18nKey: 'APP.URLS' }, ]; } else { @@ -383,6 +400,7 @@ export class AppDetailComponent implements OnInit, OnDestroy { { id: 'token', i18nKey: 'APP.TOKEN' }, { id: 'redirect-uris', i18nKey: 'APP.OIDC.REDIRECTSECTIONTITLE' }, { id: 'additional-origins', i18nKey: 'APP.ADDITIONALORIGINS' }, + { id: 'native-app-links', i18nKey: 'APP.OIDC.APPLINKSSECTION' }, { id: 'urls', i18nKey: 'APP.URLS' }, ]; } @@ -465,6 +483,12 @@ export class AppDetailComponent implements OnInit, OnDestroy { if (this.app.oidcConfig) { this.oidcForm.patchValue(this.app.oidcConfig); this.oidcTokenForm.patchValue(this.app.oidcConfig); + this.oidcForm.controls['iosTeamId'].setValue(this.app.oidcConfig.ios?.teamId ?? ''); + this.oidcForm.controls['iosBundleId'].setValue(this.app.oidcConfig.ios?.bundleId ?? ''); + this.oidcForm.controls['androidPackageName'].setValue(this.app.oidcConfig.android?.packageName ?? ''); + this.oidcForm.controls['androidSha256CertFingerprints'].setValue( + (this.app.oidcConfig.android?.sha256CertFingerprintsList ?? []).join('\n'), + ); } if (this.app.apiConfig) { this.apiForm.patchValue(this.app.apiConfig); @@ -657,96 +681,106 @@ export class AppDetailComponent implements OnInit, OnDestroy { public saveOIDCApp(): void { this.requestRedirectValuesSubject$.next(); - if (this.oidcForm.valid) { - if (this.app?.oidcConfig) { - // configuration - this.app.oidcConfig.responseTypesList = this.responseTypesList?.value; - this.app.oidcConfig.grantTypesList = this.grantTypesList?.value; - this.app.oidcConfig.appType = this.appType?.value; - this.app.oidcConfig.authMethodType = this.authMethodType?.value; - this.app.oidcConfig.backChannelLogoutUri = this.backChannelLogoutURI?.value; + if (!this.oidcForm.valid) { + this.markNativeAppLinkFieldsTouched(); + return; + } + if (this.app?.oidcConfig) { + // configuration + this.app.oidcConfig.responseTypesList = this.responseTypesList?.value; + this.app.oidcConfig.grantTypesList = this.grantTypesList?.value; + this.app.oidcConfig.appType = this.appType?.value; + this.app.oidcConfig.authMethodType = this.authMethodType?.value; + this.app.oidcConfig.backChannelLogoutUri = this.backChannelLogoutURI?.value; - // token - this.app.oidcConfig.accessTokenType = this.accessTokenType?.value; - this.app.oidcConfig.accessTokenRoleAssertion = this.accessTokenRoleAssertion?.value; - this.app.oidcConfig.idTokenRoleAssertion = this.idTokenRoleAssertion?.value; - this.app.oidcConfig.idTokenUserinfoAssertion = this.idTokenUserinfoAssertion?.value; + // token + this.app.oidcConfig.accessTokenType = this.accessTokenType?.value; + this.app.oidcConfig.accessTokenRoleAssertion = this.accessTokenRoleAssertion?.value; + this.app.oidcConfig.idTokenRoleAssertion = this.idTokenRoleAssertion?.value; + this.app.oidcConfig.idTokenUserinfoAssertion = this.idTokenUserinfoAssertion?.value; - // redirects - this.app.oidcConfig.redirectUrisList = this.redirectUrisList; - this.app.oidcConfig.postLogoutRedirectUrisList = this.postLogoutRedirectUrisList; - this.app.oidcConfig.additionalOriginsList = this.additionalOriginsList; - this.app.oidcConfig.devMode = !!this.devMode?.value; - this.app.oidcConfig.skipNativeAppSuccessPage = !!this.skipNativeAppSuccessPage?.value; + // redirects + this.app.oidcConfig.redirectUrisList = this.redirectUrisList; + this.app.oidcConfig.postLogoutRedirectUrisList = this.postLogoutRedirectUrisList; + this.app.oidcConfig.additionalOriginsList = this.additionalOriginsList; + this.app.oidcConfig.devMode = !!this.devMode?.value; + this.app.oidcConfig.skipNativeAppSuccessPage = !!this.skipNativeAppSuccessPage?.value; - const loginVersion: MessageInitShape = this.oidcLoginV2?.value - ? { - version: { - case: 'loginV2', - value: { baseUri: this.oidcLoginV2BaseURL?.value }, - }, - } - : { - version: { - case: 'loginV1', - value: {}, - }, - }; - - const req: MessageInitShape = { - projectId: this.projectId, - applicationId: this.app.id, - applicationType: { - case: 'oidcConfiguration', - value: { - // configuration - responseTypes: this.app.oidcConfig.responseTypesList.map((type) => this.toOIDCV2ResponseType(type)), - authMethodType: this.app.oidcConfig.authMethodType as unknown as OIDCV2AuthMethodType, - grantTypes: this.app.oidcConfig.grantTypesList as unknown as OIDCV2GrantType[], - applicationType: this.app.oidcConfig.appType as unknown as OIDCV2ApplicationType, - backChannelLogoutUri: this.app.oidcConfig.backChannelLogoutUri, - loginVersion, - - // token - accessTokenType: this.app.oidcConfig.accessTokenType as unknown as OIDCV2TokenType, - accessTokenRoleAssertion: this.app.oidcConfig.accessTokenRoleAssertion, - idTokenRoleAssertion: this.app.oidcConfig.idTokenRoleAssertion, - idTokenUserinfoAssertion: this.app.oidcConfig.idTokenUserinfoAssertion, - - // redirects - redirectUris: this.normalizeOIDCListForUpdate(this.app.oidcConfig.redirectUrisList), - additionalOrigins: this.app.oidcConfig.additionalOriginsList, - postLogoutRedirectUris: this.normalizeOIDCListForUpdate(this.app.oidcConfig.postLogoutRedirectUrisList), - developmentMode: this.app.oidcConfig.devMode, - skipNativeAppSuccessPage: this.app.oidcConfig.skipNativeAppSuccessPage, - ...(this.clockSkewSeconds?.value - ? { - clockSkew: { - seconds: BigInt(Math.floor(this.clockSkewSeconds?.value)), - nanos: Math.floor(this.clockSkewSeconds?.value % 1) * 10000, - }, - } - : {}), + const loginVersion: MessageInitShape = this.oidcLoginV2?.value + ? { + version: { + case: 'loginV2', + value: { baseUri: this.oidcLoginV2BaseURL?.value }, }, - }, - }; + } + : { + version: { + case: 'loginV1', + value: {}, + }, + }; - this.applicationService - .updateApplication(req) - .then(() => { - if (this.app?.oidcConfig) { - const config = { oidc: this.app.oidcConfig }; - this.currentAuthMethod = this.authMethodFromPartialConfig(config); - } - this.toast.showInfo('APP.TOAST.OIDCUPDATED', true); - setTimeout(() => { - this.getData(this.projectId, this.appId); - }, 1000); - }) - .catch((error) => { - this.toast.showError(error); - }); - } + const req: MessageInitShape = { + projectId: this.projectId, + applicationId: this.app.id, + applicationType: { + case: 'oidcConfiguration', + value: { + // configuration + responseTypes: this.app.oidcConfig.responseTypesList.map((type) => this.toOIDCV2ResponseType(type)), + authMethodType: this.app.oidcConfig.authMethodType as unknown as OIDCV2AuthMethodType, + grantTypes: this.app.oidcConfig.grantTypesList as unknown as OIDCV2GrantType[], + applicationType: this.app.oidcConfig.appType as unknown as OIDCV2ApplicationType, + backChannelLogoutUri: this.app.oidcConfig.backChannelLogoutUri, + loginVersion, + + // token + accessTokenType: this.app.oidcConfig.accessTokenType as unknown as OIDCV2TokenType, + accessTokenRoleAssertion: this.app.oidcConfig.accessTokenRoleAssertion, + idTokenRoleAssertion: this.app.oidcConfig.idTokenRoleAssertion, + idTokenUserinfoAssertion: this.app.oidcConfig.idTokenUserinfoAssertion, + + // redirects + redirectUris: this.normalizeOIDCListForUpdate(this.app.oidcConfig.redirectUrisList), + additionalOrigins: this.app.oidcConfig.additionalOriginsList, + postLogoutRedirectUris: this.normalizeOIDCListForUpdate(this.app.oidcConfig.postLogoutRedirectUrisList), + developmentMode: this.app.oidcConfig.devMode, + skipNativeAppSuccessPage: this.app.oidcConfig.skipNativeAppSuccessPage, + ios: { + teamId: (this.iosTeamId?.value ?? '').trim(), + bundleId: (this.iosBundleId?.value ?? '').trim(), + }, + android: { + packageName: (this.androidPackageName?.value ?? '').trim(), + sha256CertFingerprints: this.parseAndroidFingerprints(this.androidSha256CertFingerprints?.value), + }, + ...(this.clockSkewSeconds?.value + ? { + clockSkew: { + seconds: BigInt(Math.floor(this.clockSkewSeconds?.value)), + nanos: Math.floor(this.clockSkewSeconds?.value % 1) * 10000, + }, + } + : {}), + }, + }, + }; + + this.applicationService + .updateApplication(req) + .then(() => { + if (this.app?.oidcConfig) { + const config = { oidc: this.app.oidcConfig }; + this.currentAuthMethod = this.authMethodFromPartialConfig(config); + } + this.toast.showInfo('APP.TOAST.OIDCUPDATED', true); + setTimeout(() => { + this.getData(this.projectId, this.appId); + }, 1000); + }) + .catch((error) => { + this.toast.showError(error); + }); } } @@ -754,6 +788,16 @@ export class AppDetailComponent implements OnInit, OnDestroy { return values.length === 0 ? [''] : values; } + private parseAndroidFingerprints(value: unknown): string[] { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + return value + .split(/[\n,]+/) + .map((fp) => fp.trim()) + .filter((fp) => fp.length > 0); + } + private toOIDCV2ResponseType(type: OIDCResponseType): OIDCV2ResponseType { switch (type) { case OIDCResponseType.OIDC_RESPONSE_TYPE_CODE: @@ -943,6 +987,49 @@ export class AppDetailComponent implements OnInit, OnDestroy { return this.oidcForm.get('skipNativeAppSuccessPage') as FormControl; } + public get iosTeamId(): AbstractControl | null { + return this.oidcForm.get('iosTeamId'); + } + + public get iosBundleId(): AbstractControl | null { + return this.oidcForm.get('iosBundleId'); + } + + public get androidPackageName(): AbstractControl | null { + return this.oidcForm.get('androidPackageName'); + } + + public get androidSha256CertFingerprints(): AbstractControl | null { + return this.oidcForm.get('androidSha256CertFingerprints'); + } + + /** Defer cross-field warnings until the user has visited the paired empty field. */ + public showIosAppLinkIncompleteWarning(): boolean { + if (!this.oidcForm.hasError('iosAppLinkIncomplete')) { + return false; + } + const teamId = String(this.iosTeamId?.value ?? '').trim(); + const bundleId = String(this.iosBundleId?.value ?? '').trim(); + if (teamId !== '' && bundleId === '') { + return !!this.iosBundleId?.touched; + } + if (bundleId !== '' && teamId === '') { + return !!this.iosTeamId?.touched; + } + return false; + } + + public showAndroidPackageRequiredWarning(): boolean { + return this.oidcForm.hasError('androidAppLinkPackageRequired') && !!this.androidPackageName?.touched; + } + + private markNativeAppLinkFieldsTouched(): void { + this.iosTeamId?.markAsTouched(); + this.iosBundleId?.markAsTouched(); + this.androidPackageName?.markAsTouched(); + this.androidSha256CertFingerprints?.markAsTouched(); + } + public get accessTokenType(): AbstractControl | null { return this.oidcTokenForm.get('accessTokenType'); } @@ -1006,3 +1093,49 @@ export class AppDetailComponent implements OnInit, OnDestroy { } } } + +function iosTeamIdValidator(c: AbstractControl): ValidationErrors | null { + const value = String(c.value ?? '').trim(); + if (value === '') { + return null; + } + if (!/^[A-Z0-9]+$/.test(value)) { + return { + invalid: true, + errorsinvalidformat: { valid: false, i18nKey: 'ERRORS.INVALID_FORMAT' }, + }; + } + if (value.length !== 10) { + return { + invalid: true, + errorsapoidcteamidlength: { valid: false, i18nKey: 'APP.OIDC.TEAMIDLENGTH' }, + }; + } + return null; +} + +/** Mirrors backend AppLinkConfigValid: iOS fields together; package required when fingerprints are set. */ +function appLinkConfigValidator(): ValidatorFn { + return (group: AbstractControl): ValidationErrors | null => { + const teamId = String(group.get('iosTeamId')?.value ?? '').trim(); + const bundleId = String(group.get('iosBundleId')?.value ?? '').trim(); + const packageName = String(group.get('androidPackageName')?.value ?? '').trim(); + const fingerprintsRaw = group.get('androidSha256CertFingerprints')?.value; + const fingerprints = + typeof fingerprintsRaw === 'string' + ? fingerprintsRaw + .split(/[\n,]+/) + .map((fp) => fp.trim()) + .filter((fp) => fp.length > 0) + : []; + + const errors: ValidationErrors = {}; + if ((teamId === '') !== (bundleId === '')) { + errors['iosAppLinkIncomplete'] = true; + } + if (fingerprints.length > 0 && packageName === '') { + errors['androidAppLinkPackageRequired'] = true; + } + return Object.keys(errors).length ? errors : null; + }; +} diff --git a/console/src/assets/i18n/ar.json b/console/src/assets/i18n/ar.json index f43fe3632..9e8cd600f 100644 --- a/console/src/assets/i18n/ar.json +++ b/console/src/assets/i18n/ar.json @@ -2610,6 +2610,25 @@ "DEVMODEDESC": "احذر: مع تمكين وضع التطوير، لن يتم التحقق من عناوين URI لإعادة التوجيه.", "SKIPNATIVEAPPSUCCESSPAGE": "تخطي صفحة نجاح تسجيل الدخول", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "تخطي صفحة النجاح بعد تسجيل الدخول لهذا التطبيق الأصلي.", + "APPLINKSSECTION": "روابط التطبيقات الأصلية", + "APPLINKSDESC": "قم بتهيئة ارتباط iOS وAndroid حتى تتمكن هذه المثيلة من تقديم ملفات الثقة بـ Passkey لتطبيقاتك الأصلية.", + "IOSTITLE": "iOS", + "IOSDESC": "تُقدَّم في apple-app-site-association كمدخل webcredentials بالصيغة TEAMID.BUNDLEID.", + "TEAMID": "معرّف الفريق", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "يجب أن يكون Apple Team ID مكونًا من 10 أحرف أبجدية رقمية بالضبط، مثل ABCDE12345.", + "TEAMIDLENGTH": "يجب أن يكون Team ID مكونًا من 10 أحرف أبجدية رقمية بالضبط.", + "BUNDLEID": "معرّف الحزمة", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "معرّف حزمة iOS (CFBundleIdentifier)، مثل com.example.app. لا تُدرج بادئة Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "تُقدَّم في assetlinks.json لبيانات اعتماد Passkey عبر Digital Asset Links.", + "PACKAGENAME": "اسم الحزمة", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "معرّف تطبيق Android / اسم الحزمة (applicationId)، مثل com.example.app.", + "FINGERPRINTS": "بصمات شهادة SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "بصمة واحدة لكل سطر (64 حرفًا سادس عشريًا، النقطتان اختياريتان). أدرج إصداري التصحيح والإصدار حسب الحاجة.", "REDIRECT": "عناوين URI لإعادة التوجيه", "REDIRECTSECTION": "عناوين URI لإعادة التوجيه", "POSTLOGOUTREDIRECT": "عناوين URI بعد تسجيل الخروج", @@ -2673,7 +2692,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "عنوان URI لتسجيل الخروج من القناة الخلفية" + "BACKCHANNELLOOGOUTURI": "عنوان URI لتسجيل الخروج من القناة الخلفية", + "IOSAPPLINKINCOMPLETE": "يجب تعيين معرّف فريق iOS ومعرّف الحزمة معًا، أو تركهما فارغين معًا.", + "ANDROIDPACKAGEREQUIRED": "اسم حزمة Android مطلوب عند تعيين بصمات شهادة." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 11a0f3e0c..885d0ccad 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -2618,6 +2618,25 @@ "DEVMODEDESC": "Внимание: При активиран режим на разработка URI адресите за пренасочване няма да бъдат валидирани.", "SKIPNATIVEAPPSUCCESSPAGE": "Пропуснете страницата за успешно влизане", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Пропуснете страницата за успех след влизане в това родно приложение.", + "APPLINKSSECTION": "Връзки за нативни приложения", + "APPLINKSDESC": "Конфигурирайте iOS и Android асоциации, за да може тази инстанция да предоставя trust файлове за passkeys на вашите нативни приложения.", + "IOSTITLE": "iOS", + "IOSDESC": "Предоставя се в apple-app-site-association като webcredentials запис TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID трябва да е точно 10 буквено-цифрови знака, напр. ABCDE12345.", + "TEAMIDLENGTH": "Team ID трябва да е точно 10 буквено-цифрови знака.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), напр. com.example.app. Не включвайте префикса Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Предоставя се в assetlinks.json за Digital Asset Links / passkey credentials.", + "PACKAGENAME": "Име на пакета", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / име на пакет, напр. com.example.app.", + "FINGERPRINTS": "SHA-256 сертификатни отпечатъци", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Един отпечатък на ред (64 шестнадесетични знака, двоеточията са по избор). Добавете debug и release при нужда.", "REDIRECT": "Пренасочване на URI", "REDIRECTSECTION": "Пренасочване на URI", "POSTLOGOUTREDIRECT": "Публикувайте URI адреси за излизане", @@ -2681,7 +2700,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID и Bundle ID трябва да бъдат зададени заедно или и двете да останат празни.", + "ANDROIDPACKAGEREQUIRED": "Името на Android пакета е задължително, когато са зададени сертификатни пръстови отпечатъци." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 939e155a7..5b5b22bb8 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -2600,6 +2600,25 @@ "DEVMODEDESC": "Pozor: S povoleným vývojovým režimem nebudou URI pro přesměrování ověřovány.", "SKIPNATIVEAPPSUCCESSPAGE": "Přeskočit stránku úspěchu přihlášení", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Přeskočit stránku úspěchu po přihlášení pro tuto nativní aplikaci.", + "APPLINKSSECTION": "Odkazy pro nativní aplikace", + "APPLINKSDESC": "Nakonfigurujte asociaci iOS a Android, aby tato instance mohla poskytovat trust soubory pro passkeys vašich nativních aplikací.", + "IOSTITLE": "iOS", + "IOSDESC": "Poskytováno v apple-app-site-association jako webcredentials položka TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID musí mít přesně 10 alfanumerických znaků, např. ABCDE12345.", + "TEAMIDLENGTH": "Team ID musí mít přesně 10 alfanumerických znaků.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), např. com.example.app. Nezahrnujte prefix Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Poskytováno v assetlinks.json pro Digital Asset Links / passkey credentials.", + "PACKAGENAME": "Název balíčku", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / název balíčku, např. com.example.app.", + "FINGERPRINTS": "SHA-256 otisky certifikátů", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Jeden otisk na řádek (64 hexadecimálních znaků, dvojtečky volitelně). Podle potřeby uveďte debug i release.", "REDIRECT": "URI pro přesměrování", "REDIRECTSECTION": "URI pro přesměrování", "POSTLOGOUTREDIRECT": "URI po odhlášení", @@ -2663,7 +2682,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID a Bundle ID musí být nastaveny společně, nebo obě ponechány prázdné.", + "ANDROIDPACKAGEREQUIRED": "Název balíčku Android je povinný, pokud jsou nastaveny otisky certifikátu." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index f780fb881..269e8f72e 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -2591,6 +2591,25 @@ "DEVMODEDESC": "Bei eingeschaltetem Entwicklermodus werden die Weiterleitungs-URIs im OIDC-Flow nicht validiert.", "SKIPNATIVEAPPSUCCESSPAGE": "Login Erfolgseite überspringen", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Erfolgseite nach dem Login für diese Native Applikation überspringen.", + "APPLINKSSECTION": "Native App Links", + "APPLINKSDESC": "Konfigurieren Sie iOS- und Android-Zuordnungen, damit diese Instanz Passkey-Trust-Dateien für Ihre nativen Apps bereitstellen kann.", + "IOSTITLE": "iOS", + "IOSDESC": "Wird in apple-app-site-association als webcredentials-Eintrag TEAMID.BUNDLEID ausgeliefert.", + "TEAMID": "Team-ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team-ID muss genau 10 alphanumerische Zeichen haben, z. B. ABCDE12345.", + "TEAMIDLENGTH": "Team-ID muss genau 10 alphanumerische Zeichen haben.", + "BUNDLEID": "Bundle-ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), z. B. com.example.app. Ohne Team-ID-Präfix.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Wird in assetlinks.json für Digital Asset Links / Passkey-Credentials ausgeliefert.", + "PACKAGENAME": "Package Name", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / Package Name, z. B. com.example.app.", + "FINGERPRINTS": "SHA-256 Zertifikatsfingerabdrücke", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Ein Fingerabdruck pro Zeile (64 Hex-Zeichen, Doppelpunkte optional). Debug und Release bei Bedarf angeben.", "REDIRECT": "Weiterleitungs-URIs", "REDIRECTSECTION": "Weiterleitungs-URIs", "POSTLOGOUTREDIRECT": "URIs für Post-Log-out", @@ -2654,7 +2673,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID und Bundle ID müssen beide gesetzt oder beide leer sein.", + "ANDROIDPACKAGEREQUIRED": "Android-Paketname ist erforderlich, wenn Zertifikat-Fingerprints gesetzt sind." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index a1669c236..08fceab66 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -2622,6 +2622,27 @@ "DEVMODEDESC": "Beware: With development mode enabled redirect URIs will not be validated.", "SKIPNATIVEAPPSUCCESSPAGE": "Skip Login Success Page", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Skip the success page after a login for this native app.", + "APPLINKSSECTION": "Native App Links", + "APPLINKSDESC": "Configure iOS and Android association so this instance can serve passkey trust files for your native apps.", + "IOSTITLE": "iOS", + "IOSDESC": "Served in apple-app-site-association as a webcredentials entry TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID must be exactly 10 alphanumeric characters, e.g. ABCDE12345.", + "TEAMIDLENGTH": "Team ID must be exactly 10 alphanumeric characters.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), e.g. com.example.app. Do not include the Team ID prefix.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Served in assetlinks.json for Digital Asset Links / passkey credentials.", + "PACKAGENAME": "Package Name", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / package name, e.g. com.example.app.", + "FINGERPRINTS": "SHA-256 Certificate Fingerprints", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "One fingerprint per line (64 hex characters, optional colons). Include debug and release as needed.", + "IOSAPPLINKINCOMPLETE": "iOS Team ID and Bundle ID must both be set, or both left empty.", + "ANDROIDPACKAGEREQUIRED": "Android package name is required when certificate fingerprints are set.", "REDIRECT": "Redirect URIs", "REDIRECTSECTION": "Redirect URIs", "POSTLOGOUTREDIRECT": "Post Logout URIs", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 4304e2805..107a67097 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -2588,6 +2588,25 @@ "DEVMODEDESC": "Cuidado: Si el modo de desarrollo está activado las URIs de redirección no serán validadas.", "SKIPNATIVEAPPSUCCESSPAGE": "Saltar página de inicio de sesión con éxito", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Sáltate la página de éxito después de iniciar sesión en esta app nativa.", + "APPLINKSSECTION": "Enlaces de aplicaciones nativas", + "APPLINKSDESC": "Configure la asociación de iOS y Android para que esta instancia pueda servir archivos de confianza de passkeys para sus aplicaciones nativas.", + "IOSTITLE": "iOS", + "IOSDESC": "Se sirve en apple-app-site-association como entrada webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "El Apple Team ID debe tener exactamente 10 caracteres alfanuméricos, p. ej. ABCDE12345.", + "TEAMIDLENGTH": "El Team ID debe tener exactamente 10 caracteres alfanuméricos.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "Identificador de paquete de iOS (CFBundleIdentifier), p. ej. com.example.app. No incluya el prefijo del Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Se sirve en assetlinks.json para Digital Asset Links / credenciales de passkey.", + "PACKAGENAME": "Nombre del paquete", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "applicationId / nombre del paquete de Android, p. ej. com.example.app.", + "FINGERPRINTS": "Huellas SHA-256 del certificado", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Una huella por línea (64 caracteres hexadecimales, los dos puntos son opcionales). Incluya debug y release según sea necesario.", "REDIRECT": "URIs de redirección", "REDIRECTSECTION": "URIs de redirección", "POSTLOGOUTREDIRECT": "URIs a usar tras cerrar sesión", @@ -2651,7 +2670,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "El Team ID y el Bundle ID de iOS deben estar ambos configurados, o ambos vacíos.", + "ANDROIDPACKAGEREQUIRED": "El nombre del paquete de Android es obligatorio cuando se definen huellas de certificado." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index e16a73d4d..fcff99e8a 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -2592,6 +2592,25 @@ "DEVMODEDESC": "Attention", "SKIPNATIVEAPPSUCCESSPAGE": "Sauter la page de succès de connexion", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Sauter la page de succès après la connexion pour cette application native", + "APPLINKSSECTION": "Liens d'applications natives", + "APPLINKSDESC": "Configurez l'association iOS et Android afin que cette instance puisse servir les fichiers de confiance passkey pour vos applications natives.", + "IOSTITLE": "iOS", + "IOSDESC": "Servi dans apple-app-site-association comme entrée webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "L'Apple Team ID doit comporter exactement 10 caractères alphanumériques, p. ex. ABCDE12345.", + "TEAMIDLENGTH": "L'ID d'équipe doit comporter exactement 10 caractères alphanumériques.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "Identifiant de bundle iOS (CFBundleIdentifier), p. ex. com.example.app. N'incluez pas le préfixe Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Servi dans assetlinks.json pour Digital Asset Links / identifiants passkey.", + "PACKAGENAME": "Nom du package", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "applicationId / nom de package Android, p. ex. com.example.app.", + "FINGERPRINTS": "Empreintes de certificat SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Une empreinte par ligne (64 caractères hexadécimaux, deux-points optionnels). Incluez debug et release si nécessaire.", "REDIRECT": "Rediriger les URIs", "REDIRECTSECTION": "URIs de redirection", "POSTLOGOUTREDIRECT": "URIs de post-déconnexion", @@ -2655,7 +2674,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "L'ID d'équipe iOS et le Bundle ID doivent tous deux être renseignés, ou tous deux laissés vides.", + "ANDROIDPACKAGEREQUIRED": "Le nom de package Android est requis lorsque des empreintes de certificat sont définies." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index eee5d0361..d32e7eebd 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -2620,6 +2620,25 @@ "DEVMODEDESC": "Figyelem: Ha a fejlesztői mód be van kapcsolva, a redirect URI-k nem lesznek érvényesítve.", "SKIPNATIVEAPPSUCCESSPAGE": "Login sikeroldal kihagyása", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Hagyd ki a sikeroldalt a bejelentkezés után ennél a helyi alkalmazásnál.", + "APPLINKSSECTION": "Natív alkalmazáshivatkozások", + "APPLINKSDESC": "Állítsa be az iOS és Android társítást, hogy ez a példány passkey trust fájlokat szolgálhasson ki a natív alkalmazásaihoz.", + "IOSTITLE": "iOS", + "IOSDESC": "Az apple-app-site-association fájlban webcredentials bejegyzésként jelenik meg: TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Az Apple Team ID pontosan 10 alfanumerikus karakterből álljon, pl. ABCDE12345.", + "TEAMIDLENGTH": "A Team ID pontosan 10 alfanumerikus karakterből álljon.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), pl. com.example.app. Ne tartalmazza a Team ID előtagot.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Az assetlinks.json fájlban Digital Asset Links / passkey hitelesítő adatokhoz szolgál.", + "PACKAGENAME": "Csomagnév", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / csomagnév, pl. com.example.app.", + "FINGERPRINTS": "SHA-256 tanúsítvány ujjlenyomatok", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Soronként egy ujjlenyomat (64 hexadecimális karakter, a kettőspontok opcionálisak). Szükség szerint adja meg a debug és release értékeket.", "REDIRECT": "Redirect URIs", "REDIRECTSECTION": "Redirect URIs", "POSTLOGOUTREDIRECT": "Kijelentkezés utáni URIs", @@ -2683,7 +2702,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "Az iOS Team ID-t és Bundle ID-t együtt kell megadni, vagy mindkettőt üresen hagyni.", + "ANDROIDPACKAGEREQUIRED": "Android csomagnév kötelező, ha tanúsítvány ujjlenyomatok vannak megadva." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index a09066f8b..bcccf186b 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -2614,6 +2614,25 @@ "DEVMODEDESC": "Hati-hati: Dengan mengaktifkan mode pengembangan, URI pengalihan tidak akan divalidasi.", "SKIPNATIVEAPPSUCCESSPAGE": "Lewati Halaman Sukses Login", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Lewati halaman sukses setelah login untuk aplikasi asli ini.", + "APPLINKSSECTION": "Tautan Aplikasi Native", + "APPLINKSDESC": "Konfigurasikan asosiasi iOS dan Android agar instance ini dapat menyajikan file kepercayaan passkey untuk aplikasi native Anda.", + "IOSTITLE": "iOS", + "IOSDESC": "Disajikan di apple-app-site-association sebagai entri webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID harus tepat 10 karakter alfanumerik, mis. ABCDE12345.", + "TEAMIDLENGTH": "Team ID harus tepat 10 karakter alfanumerik.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), mis. com.example.app. Jangan sertakan awalan Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Disajikan di assetlinks.json untuk Digital Asset Links / kredensial passkey.", + "PACKAGENAME": "Nama Paket", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / nama paket, mis. com.example.app.", + "FINGERPRINTS": "Sidik Jari Sertifikat SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Satu sidik jari per baris (64 karakter heksadesimal, titik dua opsional). Sertakan debug dan release sesuai kebutuhan.", "REDIRECT": "Arahkan ulang URI", "REDIRECTSECTION": "Arahkan ulang URI", "POSTLOGOUTREDIRECT": "URI Posting Logout", @@ -2677,7 +2696,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "Team ID dan Bundle ID iOS harus keduanya diisi, atau keduanya dikosongkan.", + "ANDROIDPACKAGEREQUIRED": "Nama paket Android wajib diisi bila sidik jari sertifikat diatur." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 25efe6954..1b97c3147 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -2592,6 +2592,25 @@ "DEVMODEDESC": "Attenzione: Con la modalità di sviluppo abilitata, gli URI di reindirizzamento non saranno convalidati.", "SKIPNATIVEAPPSUCCESSPAGE": "Salta la pagina di successo dopo il login", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Salta la pagina di successo dopo il login per questa applicazione nativa", + "APPLINKSSECTION": "Collegamenti app native", + "APPLINKSDESC": "Configura l'associazione iOS e Android in modo che questa istanza possa fornire i file di trust passkey per le tue app native.", + "IOSTITLE": "iOS", + "IOSDESC": "Fornito in apple-app-site-association come voce webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "L'Apple Team ID deve contenere esattamente 10 caratteri alfanumerici, ad es. ABCDE12345.", + "TEAMIDLENGTH": "Il Team ID deve contenere esattamente 10 caratteri alfanumerici.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "Bundle Identifier iOS (CFBundleIdentifier), ad es. com.example.app. Non includere il prefisso Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Fornito in assetlinks.json per Digital Asset Links / credenziali passkey.", + "PACKAGENAME": "Nome del package", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "applicationId / nome del package Android, ad es. com.example.app.", + "FINGERPRINTS": "Impronte del certificato SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Un'impronta per riga (64 caratteri esadecimali, i due punti sono opzionali). Includere debug e release se necessario.", "REDIRECT": "URI per il reindrizzamento", "REDIRECTSECTION": "Reindirizzamento", "POSTLOGOUTREDIRECT": "URI post logout", @@ -2655,7 +2674,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "Team ID e Bundle ID iOS devono essere entrambi impostati oppure entrambi vuoti.", + "ANDROIDPACKAGEREQUIRED": "Il nome del package Android è obbligatorio quando sono impostate le impronte del certificato." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index dabf404f7..6a3a2048d 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -2617,6 +2617,25 @@ "DEVMODEDESC": "注意:開発モードを有効にすると、URIが認証されません。", "SKIPNATIVEAPPSUCCESSPAGE": "ログイン後に成功ページをスキップする", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "このネイティブアプリのログイン後に成功ページをスキップする", + "APPLINKSSECTION": "ネイティブアプリリンク", + "APPLINKSDESC": "このインスタンスがネイティブアプリ向けのパスキー信頼ファイルを提供できるよう、iOS / Android の関連付けを設定します。", + "IOSTITLE": "iOS", + "IOSDESC": "apple-app-site-association の webcredentials エントリ TEAMID.BUNDLEID として提供されます。", + "TEAMID": "チーム ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID は英数字ちょうど 10 文字である必要があります(例: ABCDE12345)。", + "TEAMIDLENGTH": "Team ID は英数字ちょうど 10 文字である必要があります。", + "BUNDLEID": "バンドル ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS の Bundle Identifier(CFBundleIdentifier)、例: com.example.app。Team ID プレフィックスは含めないでください。", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Digital Asset Links / パスキー認証情報用に assetlinks.json で提供されます。", + "PACKAGENAME": "パッケージ名", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android の applicationId / パッケージ名、例: com.example.app。", + "FINGERPRINTS": "SHA-256 証明書フィンガープリント", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "1 行に 1 つ(16 進 64 文字、コロンは任意)。必要に応じてデバッグ用とリリース用を含めてください。", "REDIRECT": "リダイレクトURI", "REDIRECTSECTION": "リダイレクトURI", "POSTLOGOUTREDIRECT": "ログアウトURI", @@ -2680,7 +2699,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "バックチャネルログアウトURI" + "BACKCHANNELLOOGOUTURI": "バックチャネルログアウトURI", + "IOSAPPLINKINCOMPLETE": "iOS の Team ID と Bundle ID は両方設定するか、両方とも空にしてください。", + "ANDROIDPACKAGEREQUIRED": "証明書フィンガープリントを設定する場合、Android パッケージ名は必須です。" }, "API": { "INFO": { diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index d4ea7e626..5476f8f89 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -2616,6 +2616,25 @@ "DEVMODEDESC": "주의: 개발 모드가 활성화된 경우 리디렉션 URI가 검증되지 않습니다.", "SKIPNATIVEAPPSUCCESSPAGE": "로그인 성공 페이지 건너뛰기", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "네이티브 앱 로그인 후 성공 페이지를 건너뜁니다.", + "APPLINKSSECTION": "네이티브 앱 링크", + "APPLINKSDESC": "이 인스턴스가 네이티브 앱용 패스키 신뢰 파일을 제공할 수 있도록 iOS 및 Android 연결을 구성합니다.", + "IOSTITLE": "iOS", + "IOSDESC": "apple-app-site-association의 webcredentials 항목 TEAMID.BUNDLEID로 제공됩니다.", + "TEAMID": "팀 ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID는 영숫자 정확히 10자여야 합니다(예: ABCDE12345).", + "TEAMIDLENGTH": "Team ID는 영숫자 정확히 10자여야 합니다.", + "BUNDLEID": "번들 ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier(CFBundleIdentifier), 예: com.example.app. Team ID 접두사는 포함하지 마세요.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Digital Asset Links / 패스키 자격 증명을 위해 assetlinks.json에서 제공됩니다.", + "PACKAGENAME": "패키지 이름", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / 패키지 이름, 예: com.example.app.", + "FINGERPRINTS": "SHA-256 인증서 지문", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "줄당 지문 하나(16진수 64자, 콜론 선택 사항). 필요에 따라 디버그 및 릴리스를 포함하세요.", "REDIRECT": "리디렉션 URI", "REDIRECTSECTION": "리디렉션 URI", "POSTLOGOUTREDIRECT": "로그아웃 후 리디렉션 URI", @@ -2679,7 +2698,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "백채널 로그아웃 URI" + "BACKCHANNELLOOGOUTURI": "백채널 로그아웃 URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID와 Bundle ID는 둘 다 설정하거나 둘 다 비워 두어야 합니다.", + "ANDROIDPACKAGEREQUIRED": "인증서 지문이 설정된 경우 Android 패키지 이름이 필요합니다." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index d8f8d5062..13510c17e 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -2588,6 +2588,25 @@ "DEVMODEDESC": "Внимавајте: Со овозможен Development mode, URIs за пренасочување нема да бидат валидирани.", "SKIPNATIVEAPPSUCCESSPAGE": "Прескокни страница за успешна најава", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Прескокнете ја страницата за успешна најава за оваа нативна апликација.", + "APPLINKSSECTION": "Врски за нативни апликации", + "APPLINKSDESC": "Конфигурирајте асоцијација за iOS и Android за оваа инстанца да може да доставува trust датотеки за passkeys на вашите нативни апликации.", + "IOSTITLE": "iOS", + "IOSDESC": "Се доставува во apple-app-site-association како webcredentials запис TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID мора да има точно 10 алфанумерички знаци, нпр. ABCDE12345.", + "TEAMIDLENGTH": "Team ID мора да има точно 10 алфанумерички знаци.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), нпр. com.example.app. Не го вклучувајте префиксот Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Се доставува во assetlinks.json за Digital Asset Links / passkey credentials.", + "PACKAGENAME": "Име на пакет", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / име на пакет, нпр. com.example.app.", + "FINGERPRINTS": "SHA-256 отпечатоци на сертификат", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Еден отпечаток по ред (64 хексадецимални знаци, двоточките се опционални). Вклучете debug и release по потреба.", "REDIRECT": "Redirect URIs", "REDIRECTSECTION": "Redirect URIs", "POSTLOGOUTREDIRECT": "URIs по одјавувањето", @@ -2651,7 +2670,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID и Bundle ID мора да се постават заедно, или обете да останат празни.", + "ANDROIDPACKAGEREQUIRED": "Името на Android пакетот е задолжително кога се поставени отпечатоци од сертификат." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 9cb338c7f..0479ea20c 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -2610,6 +2610,25 @@ "DEVMODEDESC": "Let op: Met ingeschakelde ontwikkelingsmodus worden redirect URI's niet gevalideerd.", "SKIPNATIVEAPPSUCCESSPAGE": "Sla Login Succes Pagina over", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Sla de succespagina na een login voor deze native app over.", + "APPLINKSSECTION": "Native app-koppelingen", + "APPLINKSDESC": "Configureer iOS- en Android-associatie zodat deze instance passkey-trustbestanden voor uw native apps kan serveren.", + "IOSTITLE": "iOS", + "IOSDESC": "Wordt geserveerd in apple-app-site-association als webcredentials-vermelding TEAMID.BUNDLEID.", + "TEAMID": "Team-ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID moet precies 10 alfanumerieke tekens zijn, bijv. ABCDE12345.", + "TEAMIDLENGTH": "Team ID moet precies 10 alfanumerieke tekens zijn.", + "BUNDLEID": "Bundle-ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), bijv. com.example.app. Neem het Team ID-voorvoegsel niet op.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Wordt geserveerd in assetlinks.json voor Digital Asset Links / passkey-credentials.", + "PACKAGENAME": "Pakketnaam", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / pakketnaam, bijv. com.example.app.", + "FINGERPRINTS": "SHA-256-certificaatvingerafdrukken", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Eén vingerafdruk per regel (64 hexadecimale tekens, dubbele punten optioneel). Voeg desgewenst debug en release toe.", "REDIRECT": "Redirect URI's", "REDIRECTSECTION": "Redirect URI's", "POSTLOGOUTREDIRECT": "Post Logout URI's", @@ -2673,7 +2692,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID en Bundle ID moeten beide zijn ingesteld, of beide leeg blijven.", + "ANDROIDPACKAGEREQUIRED": "Android-pakketnaam is verplicht wanneer certificaatfingerprints zijn ingesteld." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 11806ca69..1f255aea3 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -2591,6 +2591,25 @@ "DEVMODEDESC": "Uwaga: przy włączonym trybie rozwoju adresy URI przekierowania nie będą sprawdzane.", "SKIPNATIVEAPPSUCCESSPAGE": "Pomiń stronę sukcesu po zalogowaniu", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Pomiń stronę sukcesu po zalogowaniu dla tej Natywny aplikację", + "APPLINKSSECTION": "Łącza aplikacji natywnych", + "APPLINKSDESC": "Skonfiguruj powiązanie iOS i Android, aby ta instancja mogła serwować pliki zaufania passkey dla Twoich aplikacji natywnych.", + "IOSTITLE": "iOS", + "IOSDESC": "Serwowane w apple-app-site-association jako wpis webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID musi mieć dokładnie 10 znaków alfanumerycznych, np. ABCDE12345.", + "TEAMIDLENGTH": "Team ID musi mieć dokładnie 10 znaków alfanumerycznych.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), np. com.example.app. Nie dołączaj prefiksu Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Serwowane w assetlinks.json dla Digital Asset Links / poświadczeń passkey.", + "PACKAGENAME": "Nazwa pakietu", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / nazwa pakietu, np. com.example.app.", + "FINGERPRINTS": "Odciski certyfikatu SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Jeden odcisk w wierszu (64 znaki szesnastkowe, dwukropki opcjonalne). W razie potrzeby dodaj debug i release.", "REDIRECT": "Adresy URI przekierowania", "REDIRECTSECTION": "Adresy URI przekierowania", "POSTLOGOUTREDIRECT": "Adresy URI po wylogowaniu", @@ -2654,7 +2673,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "Team ID i Bundle ID iOS muszą być ustawione razem albo oba pozostawione puste.", + "ANDROIDPACKAGEREQUIRED": "Nazwa pakietu Android jest wymagana, gdy ustawiono odciski certyfikatu." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index e65176774..7184bc7ca 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -2587,6 +2587,25 @@ "DEVMODEDESC": "Atenção: Com o modo de desenvolvimento habilitado, as URIs de redirecionamento não serão validadas.", "SKIPNATIVEAPPSUCCESSPAGE": "Pular Página de Sucesso de Login", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Pule a página de sucesso após o login para este aplicativo nativo.", + "APPLINKSSECTION": "Links de aplicativos nativos", + "APPLINKSDESC": "Configure a associação de iOS e Android para que esta instância possa servir arquivos de confiança de passkeys para seus aplicativos nativos.", + "IOSTITLE": "iOS", + "IOSDESC": "Servido em apple-app-site-association como entrada webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "O Apple Team ID deve ter exatamente 10 caracteres alfanuméricos, p. ex. ABCDE12345.", + "TEAMIDLENGTH": "O Team ID deve ter exatamente 10 caracteres alfanuméricos.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "Identificador de pacote iOS (CFBundleIdentifier), p. ex. com.example.app. Não inclua o prefixo do Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Servido em assetlinks.json para Digital Asset Links / credenciais de passkey.", + "PACKAGENAME": "Nome do pacote", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "applicationId / nome do pacote Android, p. ex. com.example.app.", + "FINGERPRINTS": "Impressões digitais do certificado SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Uma impressão por linha (64 caracteres hexadecimais, dois-pontos opcionais). Inclua debug e release conforme necessário.", "REDIRECT": "URIs de Redirecionamento", "REDIRECTSECTION": "URIs de Redirecionamento", "POSTLOGOUTREDIRECT": "URIs de Pós-Logout", @@ -2650,7 +2669,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "O Team ID e o Bundle ID do iOS devem estar ambos definidos, ou ambos vazios.", + "ANDROIDPACKAGEREQUIRED": "O nome do pacote Android é obrigatório quando as impressões digitais do certificado estão definidas." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index 0a4d7e764..89ba1e100 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -2612,6 +2612,25 @@ "DEVMODEDESC": "Atenție: Cu modul de dezvoltare activat, URI-urile de redirecționare nu vor fi validate.", "SKIPNATIVEAPPSUCCESSPAGE": "Omiteți pagina de succes la conectare", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Omiteți pagina de succes după o conectare pentru această aplicație nativă.", + "APPLINKSSECTION": "Linkuri pentru aplicații native", + "APPLINKSDESC": "Configurați asocierea iOS și Android pentru ca această instanță să poată servi fișierele de încredere passkey pentru aplicațiile dvs. native.", + "IOSTITLE": "iOS", + "IOSDESC": "Servit în apple-app-site-association ca intrare webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID trebuie să aibă exact 10 caractere alfanumerice, de ex. ABCDE12345.", + "TEAMIDLENGTH": "Team ID trebuie să aibă exact 10 caractere alfanumerice.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "Identificatorul de bundle iOS (CFBundleIdentifier), de ex. com.example.app. Nu includeți prefixul Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Servit în assetlinks.json pentru Digital Asset Links / acreditări passkey.", + "PACKAGENAME": "Numele pachetului", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / numele pachetului, de ex. com.example.app.", + "FINGERPRINTS": "Amprente de certificat SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "O amprentă pe linie (64 caractere hexazecimale, două puncte opționale). Includeți debug și release după nevoie.", "REDIRECT": "URI-uri de redirecționare", "REDIRECTSECTION": "URI-uri de redirecționare", "POSTLOGOUTREDIRECT": "URI-uri de redirecționare după deconectare", @@ -2675,7 +2694,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "Team ID și Bundle ID iOS trebuie setate împreună sau ambele lăsate goale.", + "ANDROIDPACKAGEREQUIRED": "Numele pachetului Android este obligatoriu când sunt setate amprentele certificatului." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 8531d404a..a0114b9b1 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -2704,6 +2704,25 @@ "DEVMODEDESC": "Осторожно: При включённом режиме разработки URI перенаправления не будут проверяться.", "SKIPNATIVEAPPSUCCESSPAGE": "Пропустить страницу успешного входа в систему", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Пропустите страницу успеха после входа в систему для этого собственного приложения.", + "APPLINKSSECTION": "Ссылки нативных приложений", + "APPLINKSDESC": "Настройте ассоциацию iOS и Android, чтобы этот инстанс мог отдавать trust-файлы passkey для ваших нативных приложений.", + "IOSTITLE": "iOS", + "IOSDESC": "Отдаётся в apple-app-site-association как запись webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID должен состоять ровно из 10 буквенно-цифровых символов, напр. ABCDE12345.", + "TEAMIDLENGTH": "Team ID должен состоять ровно из 10 буквенно-цифровых символов.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), напр. com.example.app. Не включайте префикс Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Отдаётся в assetlinks.json для Digital Asset Links / учётных данных passkey.", + "PACKAGENAME": "Имя пакета", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / имя пакета, напр. com.example.app.", + "FINGERPRINTS": "Отпечатки сертификата SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Один отпечаток в строке (64 шестнадцатеричных символа, двоеточия необязательны). При необходимости укажите debug и release.", "REDIRECT": "Перенаправление URI", "REDIRECTSECTION": "Перенаправление URI", "POSTLOGOUTREDIRECT": "URI после выхода из системы", @@ -2767,7 +2786,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "URI выхода из системы через резервный канал" + "BACKCHANNELLOOGOUTURI": "URI выхода из системы через резервный канал", + "IOSAPPLINKINCOMPLETE": "iOS Team ID и Bundle ID должны быть заданы вместе или оба оставлены пустыми.", + "ANDROIDPACKAGEREQUIRED": "Имя пакета Android обязательно, если заданы отпечатки сертификата." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 515382478..1fa5db325 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -2619,6 +2619,25 @@ "DEVMODEDESC": "Observera: Med utvecklingsläge aktiverat kommer omdirigerings-URI:er inte att valideras.", "SKIPNATIVEAPPSUCCESSPAGE": "Hoppa över inloggningsframgångssida", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Hoppa över framgångssidan efter en inloggning för denna inhemska app.", + "APPLINKSSECTION": "Länkar för inbyggda appar", + "APPLINKSDESC": "Konfigurera iOS- och Android-association så att den här instansen kan servera passkey-trustfiler för dina inbyggda appar.", + "IOSTITLE": "iOS", + "IOSDESC": "Serveras i apple-app-site-association som webcredentials-posten TEAMID.BUNDLEID.", + "TEAMID": "Team-ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID måste vara exakt 10 alfanumeriska tecken, t.ex. ABCDE12345.", + "TEAMIDLENGTH": "Team ID måste vara exakt 10 alfanumeriska tecken.", + "BUNDLEID": "Bundle-ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), t.ex. com.example.app. Inkludera inte Team ID-prefixet.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Serveras i assetlinks.json för Digital Asset Links / passkey-uppgifter.", + "PACKAGENAME": "Paketnamn", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / paketnamn, t.ex. com.example.app.", + "FINGERPRINTS": "SHA-256-certifikatfingeravtryck", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Ett fingeravtryck per rad (64 hexadecimala tecken, kolon valfritt). Inkludera debug och release vid behov.", "REDIRECT": "Omdirigerings-URI:er", "REDIRECTSECTION": "Omdirigerings-URI:er", "POSTLOGOUTREDIRECT": "Post Logout-URI:er", @@ -2682,7 +2701,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID och Bundle ID måste båda anges, eller båda lämnas tomma.", + "ANDROIDPACKAGEREQUIRED": "Android-paketnamn krävs när certifikatfingeravtryck anges." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/tr.json b/console/src/assets/i18n/tr.json index 5538a0c8e..a13aecea4 100644 --- a/console/src/assets/i18n/tr.json +++ b/console/src/assets/i18n/tr.json @@ -2620,6 +2620,25 @@ "DEVMODEDESC": "Dikkat: Geliştirme modu etkinleştirildiğinde yönlendirme URI'leri doğrulanmayacaktır.", "SKIPNATIVEAPPSUCCESSPAGE": "Giriş Başarı Sayfasını Atla", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Bu yerel uygulama için giriş sonrası başarı sayfasını atla.", + "APPLINKSSECTION": "Yerel uygulama bağlantıları", + "APPLINKSDESC": "Bu instansın yerel uygulamalarınız için passkey güven dosyalarını sunabilmesi amacıyla iOS ve Android ilişkilendirmesini yapılandırın.", + "IOSTITLE": "iOS", + "IOSDESC": "apple-app-site-association içinde webcredentials girdisi TEAMID.BUNDLEID olarak sunulur.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID tam olarak 10 alfanümerik karakter olmalıdır, örn. ABCDE12345.", + "TEAMIDLENGTH": "Team ID tam olarak 10 alfanümerik karakter olmalıdır.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), örn. com.example.app. Team ID önekini eklemeyin.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Digital Asset Links / passkey kimlik bilgileri için assetlinks.json içinde sunulur.", + "PACKAGENAME": "Paket adı", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / paket adı, örn. com.example.app.", + "FINGERPRINTS": "SHA-256 sertifika parmak izleri", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Satır başına bir parmak izi (64 onaltılık karakter, iki nokta isteğe bağlı). Gerektiğinde debug ve release ekleyin.", "REDIRECT": "Yönlendirme URI'leri", "REDIRECTSECTION": "Yönlendirme URI'leri", "POSTLOGOUTREDIRECT": "Çıkış Sonrası URI'ler", @@ -2683,7 +2702,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI" + "BACKCHANNELLOOGOUTURI": "Back-Channel Logout URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID ve Bundle ID ikisi birden ayarlanmalı veya ikisi de boş bırakılmalıdır.", + "ANDROIDPACKAGEREQUIRED": "Sertifika parmak izleri ayarlandığında Android paket adı zorunludur." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/uk.json b/console/src/assets/i18n/uk.json index 5d93f7f65..d58823b99 100644 --- a/console/src/assets/i18n/uk.json +++ b/console/src/assets/i18n/uk.json @@ -2622,6 +2622,25 @@ "DEVMODEDESC": "Увага: з увімкненим режимом розробки URI перенаправлення не перевірятимуться.", "SKIPNATIVEAPPSUCCESSPAGE": "Пропустити сторінку успішного входу", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "Пропустити сторінку успіху після входу для цього нативного додатку.", + "APPLINKSSECTION": "Посилання нативних застосунків", + "APPLINKSDESC": "Налаштуйте асоціацію iOS та Android, щоб цей інстанс міг віддавати trust-файли passkey для ваших нативних застосунків.", + "IOSTITLE": "iOS", + "IOSDESC": "Віддається в apple-app-site-association як запис webcredentials TEAMID.BUNDLEID.", + "TEAMID": "Team ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID має складатися рівно з 10 буквено-цифрових символів, напр. ABCDE12345.", + "TEAMIDLENGTH": "Team ID має складатися рівно з 10 буквено-цифрових символів.", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier (CFBundleIdentifier), напр. com.example.app. Не включайте префікс Team ID.", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "Віддається в assetlinks.json для Digital Asset Links / облікових даних passkey.", + "PACKAGENAME": "Назва пакета", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / назва пакета, напр. com.example.app.", + "FINGERPRINTS": "Відбитки сертифіката SHA-256", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "Один відбиток у рядку (64 шістнадцяткові символи, двокрапки необов’язкові). За потреби вкажіть debug і release.", "REDIRECT": "URI перенаправлення", "REDIRECTSECTION": "URI перенаправлення", "POSTLOGOUTREDIRECT": "URI після виходу", @@ -2685,7 +2704,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "URI виходу з зворотного каналу" + "BACKCHANNELLOOGOUTURI": "URI виходу з зворотного каналу", + "IOSAPPLINKINCOMPLETE": "iOS Team ID та Bundle ID мають бути задані разом або обидва залишені порожніми.", + "ANDROIDPACKAGEREQUIRED": "Назва пакета Android обов’язкова, якщо задано відбитки сертифіката." }, "API": { "INFO": { diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index e7a9932c6..25cdd9df6 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -2624,6 +2624,25 @@ "DEVMODEDESC": "注意:启用开发模式的重定向 URI 将不会被验证。", "SKIPNATIVEAPPSUCCESSPAGE": "登录后跳过成功页面", "SKIPNATIVEAPPSUCCESSPAGE_DESCRIPTION": "登录后跳过本机应用的成功页面", + "APPLINKSSECTION": "原生应用关联", + "APPLINKSDESC": "配置 iOS 和 Android 关联,使此实例能够为您的原生应用提供通行密钥信任文件。", + "IOSTITLE": "iOS", + "IOSDESC": "在 apple-app-site-association 中作为 webcredentials 条目 TEAMID.BUNDLEID 提供。", + "TEAMID": "团队 ID", + "TEAMIDPLACEHOLDER": "ABCDE12345", + "TEAMIDDESC": "Apple Team ID 必须恰好为 10 个字母或数字,例如 ABCDE12345。", + "TEAMIDLENGTH": "Team ID 必须恰好为 10 个字母或数字。", + "BUNDLEID": "Bundle ID", + "BUNDLEIDPLACEHOLDER": "com.example.app", + "BUNDLEIDDESC": "iOS Bundle Identifier(CFBundleIdentifier),例如 com.example.app。请勿包含 Team ID 前缀。", + "ANDROIDTITLE": "Android", + "ANDROIDDESC": "在 assetlinks.json 中为 Digital Asset Links / 通行密钥凭据提供。", + "PACKAGENAME": "软件包名称", + "PACKAGENAMEPLACEHOLDER": "com.example.app", + "PACKAGENAMEDESC": "Android applicationId / 软件包名称,例如 com.example.app。", + "FINGERPRINTS": "SHA-256 证书指纹", + "FINGERPRINTSPLACEHOLDER": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "FINGERPRINTSDESC": "每行一个指纹(64 个十六进制字符,冒号可选)。按需包含调试版和正式版。", "REDIRECT": "重定向 URLs", "REDIRECTSECTION": "重定向 URLs", "POSTLOGOUTREDIRECT": "退出登录重定向 URLs", @@ -2687,7 +2706,9 @@ } } }, - "BACKCHANNELLOOGOUTURI": "反向通道注销URI" + "BACKCHANNELLOOGOUTURI": "反向通道注销URI", + "IOSAPPLINKINCOMPLETE": "iOS Team ID 与 Bundle ID 必须同时填写,或同时留空。", + "ANDROIDPACKAGEREQUIRED": "设置证书指纹时必须填写 Android 软件包名称。" }, "API": { "INFO": { diff --git a/internal/api/grpc/application/v2/convert/oidc_app.go b/internal/api/grpc/application/v2/convert/oidc_app.go index fc3765a43..a62a84a3a 100644 --- a/internal/api/grpc/application/v2/convert/oidc_app.go +++ b/internal/api/grpc/application/v2/convert/oidc_app.go @@ -15,30 +15,36 @@ func CreateOIDCAppRequestToDomain(name, appID, projectID string, req *applicatio if err != nil { return nil, err } + iosTeamID, iosBundleID := iosAppLinkConfigToDomain(req.GetIos()) + androidPackageName, androidFingerprints := androidAppLinkConfigToDomain(req.GetAndroid()) return &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{ AggregateID: projectID, }, - AppID: appID, - AppName: name, - OIDCVersion: gu.Ptr(domain.OIDCVersionV1), - RedirectUris: req.GetRedirectUris(), - ResponseTypes: oidcResponseTypesToDomain(req.GetResponseTypes()), - GrantTypes: oidcGrantTypesToDomain(req.GetGrantTypes()), - ApplicationType: gu.Ptr(oidcApplicationTypeToDomain(req.GetApplicationType())), - AuthMethodType: gu.Ptr(oidcAuthMethodTypeToDomain(req.GetAuthMethodType())), - PostLogoutRedirectUris: req.GetPostLogoutRedirectUris(), - DevMode: &req.DevelopmentMode, - AccessTokenType: gu.Ptr(oidcTokenTypeToDomain(req.GetAccessTokenType())), - AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), - IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), - IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), - ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), - AdditionalOrigins: req.GetAdditionalOrigins(), - SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), - BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + AppID: appID, + AppName: name, + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: req.GetRedirectUris(), + ResponseTypes: oidcResponseTypesToDomain(req.GetResponseTypes()), + GrantTypes: oidcGrantTypesToDomain(req.GetGrantTypes()), + ApplicationType: gu.Ptr(oidcApplicationTypeToDomain(req.GetApplicationType())), + AuthMethodType: gu.Ptr(oidcAuthMethodTypeToDomain(req.GetAuthMethodType())), + PostLogoutRedirectUris: req.GetPostLogoutRedirectUris(), + DevMode: &req.DevelopmentMode, + AccessTokenType: gu.Ptr(oidcTokenTypeToDomain(req.GetAccessTokenType())), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), + AdditionalOrigins: req.GetAdditionalOrigins(), + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + IOSTeamID: iosTeamID, + IOSBundleID: iosBundleID, + AndroidPackageName: androidPackageName, + AndroidSHA256CertFingerprints: androidFingerprints, }, nil } @@ -47,28 +53,35 @@ func UpdateOIDCAppConfigRequestToDomain(appID, projectID string, app *applicatio if err != nil { return nil, err } + // nested messages: omit = no change; present (incl. empty) = set/clear + iosTeamID, iosBundleID := iosAppLinkConfigToDomain(app.Ios) + androidPackageName, androidFingerprints := androidAppLinkConfigToDomain(app.Android) return &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{ AggregateID: projectID, }, - AppID: appID, - RedirectUris: app.RedirectUris, - ResponseTypes: oidcResponseTypesToDomain(app.ResponseTypes), - GrantTypes: oidcGrantTypesToDomain(app.GrantTypes), - ApplicationType: oidcApplicationTypeToDomainPtr(app.ApplicationType), - AuthMethodType: oidcAuthMethodTypeToDomainPtr(app.AuthMethodType), - PostLogoutRedirectUris: app.PostLogoutRedirectUris, - DevMode: app.DevelopmentMode, - AccessTokenType: oidcTokenTypeToDomainPtr(app.AccessTokenType), - AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, - IDTokenRoleAssertion: app.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, - ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), - AdditionalOrigins: app.AdditionalOrigins, - SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, - BackChannelLogoutURI: app.BackChannelLogoutUri, - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + AppID: appID, + RedirectUris: app.RedirectUris, + ResponseTypes: oidcResponseTypesToDomain(app.ResponseTypes), + GrantTypes: oidcGrantTypesToDomain(app.GrantTypes), + ApplicationType: oidcApplicationTypeToDomainPtr(app.ApplicationType), + AuthMethodType: oidcAuthMethodTypeToDomainPtr(app.AuthMethodType), + PostLogoutRedirectUris: app.PostLogoutRedirectUris, + DevMode: app.DevelopmentMode, + AccessTokenType: oidcTokenTypeToDomainPtr(app.AccessTokenType), + AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, + IDTokenRoleAssertion: app.IdTokenRoleAssertion, + IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), + AdditionalOrigins: app.AdditionalOrigins, + SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, + BackChannelLogoutURI: app.BackChannelLogoutUri, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + IOSTeamID: iosTeamID, + IOSBundleID: iosBundleID, + AndroidPackageName: androidPackageName, + AndroidSHA256CertFingerprints: androidFingerprints, }, nil } @@ -213,10 +226,50 @@ func appOIDCConfigToPb(oidcApp *query.OIDCApp) *application.Application_OidcConf SkipNativeAppSuccessPage: oidcApp.SkipNativeAppSuccessPage, BackChannelLogoutUri: oidcApp.BackChannelLogoutURI, LoginVersion: loginVersionToPb(oidcApp.LoginVersion, oidcApp.LoginBaseURI), + Ios: iosAppLinkConfigToPb(oidcApp.IOSTeamID, oidcApp.IOSBundleID), + Android: androidAppLinkConfigToPb(oidcApp.AndroidPackageName, oidcApp.AndroidSHA256CertFingerprints), }, } } +func iosAppLinkConfigToDomain(cfg *application.IOSAppLinkConfig) (teamID, bundleID *string) { + if cfg == nil { + return nil, nil + } + return gu.Ptr(cfg.GetTeamId()), gu.Ptr(cfg.GetBundleId()) +} + +func androidAppLinkConfigToDomain(cfg *application.AndroidAppLinkConfig) (packageName *string, fingerprints []string) { + if cfg == nil { + return nil, nil + } + fps := cfg.GetSha256CertFingerprints() + if fps == nil { + fps = []string{} + } + return gu.Ptr(cfg.GetPackageName()), fps +} + +func iosAppLinkConfigToPb(teamID, bundleID string) *application.IOSAppLinkConfig { + if teamID == "" && bundleID == "" { + return nil + } + return &application.IOSAppLinkConfig{ + TeamId: teamID, + BundleId: bundleID, + } +} + +func androidAppLinkConfigToPb(packageName string, fingerprints []string) *application.AndroidAppLinkConfig { + if packageName == "" && len(fingerprints) == 0 { + return nil + } + return &application.AndroidAppLinkConfig{ + PackageName: packageName, + Sha256CertFingerprints: fingerprints, + } +} + func oidcResponseTypesFromModel(responseTypes []domain.OIDCResponseType) []application.OIDCResponseType { oidcResponseTypes := make([]application.OIDCResponseType, len(responseTypes)) for i, responseType := range responseTypes { diff --git a/internal/api/grpc/application/v2/convert/oidc_app_test.go b/internal/api/grpc/application/v2/convert/oidc_app_test.go index 58ce20ed2..b4d96f521 100644 --- a/internal/api/grpc/application/v2/convert/oidc_app_test.go +++ b/internal/api/grpc/application/v2/convert/oidc_app_test.go @@ -66,29 +66,41 @@ func TestCreateOIDCAppRequestToDomain(t *testing.T) { LoginVersion: &application.LoginVersion{Version: &application.LoginVersion_LoginV2{LoginV2: &application.LoginV2{ BaseUri: gu.Ptr("https://login"), }}}, + Ios: &application.IOSAppLinkConfig{ + TeamId: "TEAMID", + BundleId: "com.example.app", + }, + Android: &application.AndroidAppLinkConfig{ + PackageName: "com.example.app", + Sha256CertFingerprints: []string{"AA:BB:CC"}, + }, }, expectedModel: &domain.OIDCApp{ - ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, - AppName: "all fields set", - AppID: "app1", - OIDCVersion: gu.Ptr(domain.OIDCVersionV1), - RedirectUris: []string{"https://redirect"}, - ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), - AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), - PostLogoutRedirectUris: []string{"https://logout"}, - DevMode: gu.Ptr(true), - AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), - AccessTokenRoleAssertion: gu.Ptr(true), - IDTokenRoleAssertion: gu.Ptr(true), - IDTokenUserinfoAssertion: gu.Ptr(true), - ClockSkew: gu.Ptr(5 * time.Second), - AdditionalOrigins: []string{"https://origin"}, - SkipNativeAppSuccessPage: gu.Ptr(true), - BackChannelLogoutURI: gu.Ptr("https://backchannel"), - LoginVersion: gu.Ptr(domain.LoginVersion2), - LoginBaseURI: gu.Ptr("https://login"), + ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, + AppName: "all fields set", + AppID: "app1", + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + IOSTeamID: gu.Ptr("TEAMID"), + IOSBundleID: gu.Ptr("com.example.app"), + AndroidPackageName: gu.Ptr("com.example.app"), + AndroidSHA256CertFingerprints: []string{"AA:BB:CC"}, }, }, } @@ -182,6 +194,71 @@ func TestUpdateOIDCAppConfigRequestToDomain(t *testing.T) { LoginBaseURI: gu.Ptr("https://login"), }, }, + { + testName: "omit app links leaves nil", + appID: "app1", + projectID: "proj1", + req: &application.UpdateOIDCApplicationConfigurationRequest{}, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj1"}, + AppID: "app1", + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ClockSkew: gu.Ptr(time.Duration(0)), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + { + testName: "set app links", + appID: "app1", + projectID: "proj1", + req: &application.UpdateOIDCApplicationConfigurationRequest{ + Ios: &application.IOSAppLinkConfig{ + TeamId: "TEAMID", + BundleId: "com.example.app", + }, + Android: &application.AndroidAppLinkConfig{ + PackageName: "com.example.app", + Sha256CertFingerprints: []string{"AA:BB:CC"}, + }, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj1"}, + AppID: "app1", + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ClockSkew: gu.Ptr(time.Duration(0)), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + IOSTeamID: gu.Ptr("TEAMID"), + IOSBundleID: gu.Ptr("com.example.app"), + AndroidPackageName: gu.Ptr("com.example.app"), + AndroidSHA256CertFingerprints: []string{"AA:BB:CC"}, + }, + }, + { + testName: "clear app links with empty messages", + appID: "app1", + projectID: "proj1", + req: &application.UpdateOIDCApplicationConfigurationRequest{ + Ios: &application.IOSAppLinkConfig{}, + Android: &application.AndroidAppLinkConfig{}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj1"}, + AppID: "app1", + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ClockSkew: gu.Ptr(time.Duration(0)), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + IOSTeamID: gu.Ptr(""), + IOSBundleID: gu.Ptr(""), + AndroidPackageName: gu.Ptr(""), + AndroidSHA256CertFingerprints: []string{}, + }, + }, } for _, tc := range tt { @@ -453,26 +530,30 @@ func TestAppOIDCConfigToPb(t *testing.T) { { name: "full config", input: &query.OIDCApp{ - RedirectURIs: []string{"https://example.com/callback"}, - ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - AppType: domain.OIDCApplicationTypeWeb, - ClientID: "client123", - AuthMethodType: domain.OIDCAuthMethodTypeBasic, - PostLogoutRedirectURIs: []string{"https://example.com/logout"}, - ComplianceProblems: []string{"problem1", "problem2"}, - IsDevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AssertAccessTokenRole: true, - AssertIDTokenRole: true, - AssertIDTokenUserinfo: true, - ClockSkew: 5 * time.Second, - AdditionalOrigins: []string{"https://app.example.com"}, - AllowedOrigins: []string{"https://allowed.example.com"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://example.com/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: gu.Ptr("https://login.example.com"), + RedirectURIs: []string{"https://example.com/callback"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + AppType: domain.OIDCApplicationTypeWeb, + ClientID: "client123", + AuthMethodType: domain.OIDCAuthMethodTypeBasic, + PostLogoutRedirectURIs: []string{"https://example.com/logout"}, + ComplianceProblems: []string{"problem1", "problem2"}, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeBearer, + AssertAccessTokenRole: true, + AssertIDTokenRole: true, + AssertIDTokenUserinfo: true, + ClockSkew: 5 * time.Second, + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutURI: "https://example.com/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://login.example.com"), + IOSTeamID: "TEAMID", + IOSBundleID: "com.example.app", + AndroidPackageName: "com.example.app", + AndroidSHA256CertFingerprints: []string{"AA:BB:CC"}, }, expected: &application.Application_OidcConfiguration{ OidcConfiguration: &application.OIDCConfiguration{ @@ -506,6 +587,14 @@ func TestAppOIDCConfigToPb(t *testing.T) { }, }, }, + Ios: &application.IOSAppLinkConfig{ + TeamId: "TEAMID", + BundleId: "com.example.app", + }, + Android: &application.AndroidAppLinkConfig{ + PackageName: "com.example.app", + Sha256CertFingerprints: []string{"AA:BB:CC"}, + }, }, }, }, diff --git a/internal/api/grpc/application/v2/integration_test/app_link_test.go b/internal/api/grpc/application/v2/integration_test/app_link_test.go new file mode 100644 index 000000000..7a6c38843 --- /dev/null +++ b/internal/api/grpc/application/v2/integration_test/app_link_test.go @@ -0,0 +1,159 @@ +//go:build integration + +package app_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/application/v2" +) + +func TestOIDCAppLinkConfig(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), integration.ProjectName(), false, false) + + ios := &application.IOSAppLinkConfig{ + TeamId: "ABCDE12345", + BundleId: "com.example.app", + } + android := &application.AndroidAppLinkConfig{ + PackageName: "com.example.app", + Sha256CertFingerprints: []string{"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"}, + } + + t.Run("create with incomplete ios config is rejected", func(t *testing.T) { + _, err := instance.Client.ApplicationV2.CreateApplication(IAMOwnerCtx, &application.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: integration.ApplicationName(), + ApplicationType: &application.CreateApplicationRequest_OidcConfiguration{ + OidcConfiguration: baseOIDCCreateRequest(iosOnlyTeamID()), + }, + }) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("create get update omit and clear round trip", func(t *testing.T) { + created, err := instance.Client.ApplicationV2.CreateApplication(IAMOwnerCtx, &application.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: integration.ApplicationName(), + ApplicationType: &application.CreateApplicationRequest_OidcConfiguration{ + OidcConfiguration: baseOIDCCreateRequestWithAppLinks(ios, android), + }, + }) + require.NoError(t, err) + require.NotEmpty(t, created.GetApplicationId()) + + assertOIDCAppLinks(t, created.GetApplicationId(), ios, android) + + updatedIos := &application.IOSAppLinkConfig{ + TeamId: "NEWTEAM123", + BundleId: "com.example.app.updated", + } + updatedAndroid := &application.AndroidAppLinkConfig{ + PackageName: "com.example.app.updated", + Sha256CertFingerprints: []string{"11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00"}, + } + _, err = instance.Client.ApplicationV2.UpdateApplication(IAMOwnerCtx, &application.UpdateApplicationRequest{ + ProjectId: p.GetId(), + ApplicationId: created.GetApplicationId(), + ApplicationType: &application.UpdateApplicationRequest_OidcConfiguration{ + OidcConfiguration: &application.UpdateOIDCApplicationConfigurationRequest{ + Ios: updatedIos, + Android: updatedAndroid, + }, + }, + }) + require.NoError(t, err) + assertOIDCAppLinks(t, created.GetApplicationId(), updatedIos, updatedAndroid) + + _, err = instance.Client.ApplicationV2.UpdateApplication(IAMOwnerCtx, &application.UpdateApplicationRequest{ + ProjectId: p.GetId(), + ApplicationId: created.GetApplicationId(), + ApplicationType: &application.UpdateApplicationRequest_OidcConfiguration{ + OidcConfiguration: &application.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home-omitted"}, + }, + }, + }) + require.NoError(t, err) + assertOIDCAppLinks(t, created.GetApplicationId(), updatedIos, updatedAndroid) + + _, err = instance.Client.ApplicationV2.UpdateApplication(IAMOwnerCtx, &application.UpdateApplicationRequest{ + ProjectId: p.GetId(), + ApplicationId: created.GetApplicationId(), + ApplicationType: &application.UpdateApplicationRequest_OidcConfiguration{ + OidcConfiguration: &application.UpdateOIDCApplicationConfigurationRequest{ + Ios: &application.IOSAppLinkConfig{}, + Android: &application.AndroidAppLinkConfig{}, + }, + }, + }) + require.NoError(t, err) + assertOIDCAppLinks(t, created.GetApplicationId(), nil, nil) + }) +} + +func baseOIDCCreateRequest(opts ...func(*application.CreateOIDCApplicationRequest)) *application.CreateOIDCApplicationRequest { + req := &application.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []application.OIDCResponseType{application.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []application.OIDCGrantType{application.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + ApplicationType: application.OIDCApplicationType_OIDC_APP_TYPE_NATIVE, + AuthMethodType: application.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: application.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: application.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + } + for _, opt := range opts { + opt(req) + } + return req +} + +func baseOIDCCreateRequestWithAppLinks(ios *application.IOSAppLinkConfig, android *application.AndroidAppLinkConfig) *application.CreateOIDCApplicationRequest { + return baseOIDCCreateRequest(func(req *application.CreateOIDCApplicationRequest) { + req.Ios = ios + req.Android = android + }) +} + +func iosOnlyTeamID() func(*application.CreateOIDCApplicationRequest) { + return func(req *application.CreateOIDCApplicationRequest) { + req.Ios = &application.IOSAppLinkConfig{TeamId: "ABCDE12345"} + } +} + +func assertOIDCAppLinks(t *testing.T, appID string, wantIOS *application.IOSAppLinkConfig, wantAndroid *application.AndroidAppLinkConfig) { + t.Helper() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(IAMOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ct *assert.CollectT) { + res, err := instance.Client.ApplicationV2.GetApplication(IAMOwnerCtx, &application.GetApplicationRequest{ + ApplicationId: appID, + }) + require.NoError(ct, err) + cfg := res.GetApplication().GetOidcConfiguration() + require.NotNil(ct, cfg) + + if wantIOS == nil { + assert.Nil(ct, cfg.GetIos()) + } else { + require.NotNil(ct, cfg.GetIos()) + assert.Equal(ct, wantIOS.GetTeamId(), cfg.GetIos().GetTeamId()) + assert.Equal(ct, wantIOS.GetBundleId(), cfg.GetIos().GetBundleId()) + } + + if wantAndroid == nil { + assert.Nil(ct, cfg.GetAndroid()) + } else { + require.NotNil(ct, cfg.GetAndroid()) + assert.Equal(ct, wantAndroid.GetPackageName(), cfg.GetAndroid().GetPackageName()) + assert.Equal(ct, wantAndroid.GetSha256CertFingerprints(), cfg.GetAndroid().GetSha256CertFingerprints()) + } + }, retryDuration, tick) +} diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 5021870b3..82768b52c 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -47,29 +47,35 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) (*domain.OIDCApp, if err != nil { return nil, err } + iosTeamID, iosBundleID := app_grpc.IOSAppLinkConfigToDomain(req.GetIos()) + androidPackageName, androidFingerprints := app_grpc.AndroidAppLinkConfigToDomain(req.GetAndroid()) return &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{ AggregateID: req.ProjectId, }, - AppName: req.Name, - OIDCVersion: gu.Ptr(app_grpc.OIDCVersionToDomain(req.Version)), - RedirectUris: req.RedirectUris, - ResponseTypes: app_grpc.OIDCResponseTypesToDomain(req.ResponseTypes), - GrantTypes: app_grpc.OIDCGrantTypesToDomain(req.GrantTypes), - ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(req.AppType)), - AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType)), - PostLogoutRedirectUris: req.PostLogoutRedirectUris, - DevMode: gu.Ptr(req.GetDevMode()), - AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType)), - AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), - IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), - IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), - ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), - AdditionalOrigins: req.AdditionalOrigins, - SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), - BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), - LoginVersion: gu.Ptr(loginVersion), - LoginBaseURI: gu.Ptr(loginBaseURI), + AppName: req.Name, + OIDCVersion: gu.Ptr(app_grpc.OIDCVersionToDomain(req.Version)), + RedirectUris: req.RedirectUris, + ResponseTypes: app_grpc.OIDCResponseTypesToDomain(req.ResponseTypes), + GrantTypes: app_grpc.OIDCGrantTypesToDomain(req.GrantTypes), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(req.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType)), + PostLogoutRedirectUris: req.PostLogoutRedirectUris, + DevMode: gu.Ptr(req.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), + AdditionalOrigins: req.AdditionalOrigins, + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), + IOSTeamID: iosTeamID, + IOSBundleID: iosBundleID, + AndroidPackageName: androidPackageName, + AndroidSHA256CertFingerprints: androidFingerprints, }, nil } @@ -112,28 +118,35 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) if err != nil { return nil, err } + // nested messages: omit = no change; present (incl. empty) = set/clear + iosTeamID, iosBundleID := app_grpc.IOSAppLinkConfigToDomain(app.Ios) + androidPackageName, androidFingerprints := app_grpc.AndroidAppLinkConfigToDomain(app.Android) return &domain.OIDCApp{ ObjectRoot: models.ObjectRoot{ AggregateID: app.ProjectId, }, - AppID: app.AppId, - RedirectUris: app.RedirectUris, - ResponseTypes: app_grpc.OIDCResponseTypesToDomain(app.ResponseTypes), - GrantTypes: app_grpc.OIDCGrantTypesToDomain(app.GrantTypes), - ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(app.AppType)), - AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType)), - PostLogoutRedirectUris: app.PostLogoutRedirectUris, - DevMode: gu.Ptr(app.GetDevMode()), - AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType)), - AccessTokenRoleAssertion: gu.Ptr(app.GetAccessTokenRoleAssertion()), - IDTokenRoleAssertion: gu.Ptr(app.GetIdTokenRoleAssertion()), - IDTokenUserinfoAssertion: gu.Ptr(app.GetIdTokenUserinfoAssertion()), - ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), - AdditionalOrigins: app.AdditionalOrigins, - SkipNativeAppSuccessPage: gu.Ptr(app.GetSkipNativeAppSuccessPage()), - BackChannelLogoutURI: gu.Ptr(app.GetBackChannelLogoutUri()), - LoginVersion: gu.Ptr(loginVersion), - LoginBaseURI: gu.Ptr(loginBaseURI), + AppID: app.AppId, + RedirectUris: app.RedirectUris, + ResponseTypes: app_grpc.OIDCResponseTypesToDomain(app.ResponseTypes), + GrantTypes: app_grpc.OIDCGrantTypesToDomain(app.GrantTypes), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(app.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType)), + PostLogoutRedirectUris: app.PostLogoutRedirectUris, + DevMode: gu.Ptr(app.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(app.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(app.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(app.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), + AdditionalOrigins: app.AdditionalOrigins, + SkipNativeAppSuccessPage: gu.Ptr(app.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(app.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), + IOSTeamID: iosTeamID, + IOSBundleID: iosBundleID, + AndroidPackageName: androidPackageName, + AndroidSHA256CertFingerprints: androidFingerprints, }, nil } diff --git a/internal/api/grpc/project/application.go b/internal/api/grpc/project/application.go index 29fbd4cd2..41ff39eae 100644 --- a/internal/api/grpc/project/application.go +++ b/internal/api/grpc/project/application.go @@ -3,6 +3,7 @@ package project import ( "net/url" + "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/durationpb" object_grpc "github.com/zitadel/zitadel/internal/api/grpc/object" @@ -68,6 +69,8 @@ func AppOIDCConfigToPb(app *query.OIDCApp) *app_pb.App_OidcConfig { SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, BackChannelLogoutUri: app.BackChannelLogoutURI, LoginVersion: loginVersionToPb(app.LoginVersion, app.LoginBaseURI), + Ios: iosAppLinkConfigToPb(app.IOSTeamID, app.IOSBundleID), + Android: androidAppLinkConfigToPb(app.AndroidPackageName, app.AndroidSHA256CertFingerprints), }, } } @@ -345,3 +348,41 @@ func LoginVersionToDomain(version *app_pb.LoginVersion) (domain.LoginVersion, st return domain.LoginVersionUnspecified, "", nil } } + +func IOSAppLinkConfigToDomain(cfg *app_pb.IOSAppLinkConfig) (teamID, bundleID *string) { + if cfg == nil { + return nil, nil + } + return gu.Ptr(cfg.GetTeamId()), gu.Ptr(cfg.GetBundleId()) +} + +func AndroidAppLinkConfigToDomain(cfg *app_pb.AndroidAppLinkConfig) (packageName *string, fingerprints []string) { + if cfg == nil { + return nil, nil + } + fps := cfg.GetSha256CertFingerprints() + if fps == nil { + fps = []string{} + } + return gu.Ptr(cfg.GetPackageName()), fps +} + +func iosAppLinkConfigToPb(teamID, bundleID string) *app_pb.IOSAppLinkConfig { + if teamID == "" && bundleID == "" { + return nil + } + return &app_pb.IOSAppLinkConfig{ + TeamId: teamID, + BundleId: bundleID, + } +} + +func androidAppLinkConfigToPb(packageName string, fingerprints []string) *app_pb.AndroidAppLinkConfig { + if packageName == "" && len(fingerprints) == 0 { + return nil + } + return &app_pb.AndroidAppLinkConfig{ + PackageName: packageName, + Sha256CertFingerprints: fingerprints, + } +} diff --git a/internal/api/well_known/integration_test/well_known_test.go b/internal/api/well_known/integration_test/well_known_test.go new file mode 100644 index 000000000..d5e900881 --- /dev/null +++ b/internal/api/well_known/integration_test/well_known_test.go @@ -0,0 +1,187 @@ +//go:build integration + +package well_known_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/well_known" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/application/v2" +) + +var ( + CTX context.Context + IAMOwnerCtx context.Context + Instance *integration.Instance +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + CTX = ctx + IAMOwnerCtx = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + return m.Run() + }()) +} + +func TestWellKnownAppLinks(t *testing.T) { + issuer := Instance.OIDCIssuer() + + t.Run("empty when no apps configured", func(t *testing.T) { + assertEventuallyJSON(t, issuer+well_known.AppleAppSiteAssociationPath, map[string]any{ + "webcredentials": map[string]any{ + "apps": []any{}, + }, + }) + assertEventuallyJSON(t, issuer+well_known.AssetLinksPath, []any{}) + }) + + project := Instance.CreateProject(IAMOwnerCtx, t, Instance.DefaultOrg.GetId(), integration.ProjectName(), false, false) + + ios1 := &application.IOSAppLinkConfig{TeamId: "TEAMAAAAAA", BundleId: "com.example.one"} + android1 := &application.AndroidAppLinkConfig{ + PackageName: "com.example.one", + Sha256CertFingerprints: []string{"11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11"}, + } + createOIDCAppWithLinks(t, project.GetId(), ios1, android1) + + ios2 := &application.IOSAppLinkConfig{TeamId: "TEAMBBBBBB", BundleId: "com.example.two"} + createOIDCAppWithLinks(t, project.GetId(), ios2, nil) + + android2 := &application.AndroidAppLinkConfig{ + PackageName: "com.example.two", + Sha256CertFingerprints: []string{"22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22"}, + } + createOIDCAppWithLinks(t, project.GetId(), nil, android2) + + // duplicate iOS app id should be deduped in AASA + createOIDCAppWithLinks(t, project.GetId(), ios1, nil) + + assertEventuallyJSONUnorderedApps(t, issuer, + []string{ + "TEAMAAAAAA.com.example.one", + "TEAMBBBBBB.com.example.two", + }, + []any{ + map[string]any{ + "relation": []any{"delegate_permission/common.get_login_creds"}, + "target": map[string]any{ + "namespace": "android_app", + "package_name": "com.example.one", + "sha256_cert_fingerprints": []any{android1.Sha256CertFingerprints[0]}, + }, + }, + map[string]any{ + "relation": []any{"delegate_permission/common.get_login_creds"}, + "target": map[string]any{ + "namespace": "android_app", + "package_name": "com.example.two", + "sha256_cert_fingerprints": []any{android2.Sha256CertFingerprints[0]}, + }, + }, + }, + ) +} + +func createOIDCAppWithLinks(t *testing.T, projectID string, ios *application.IOSAppLinkConfig, android *application.AndroidAppLinkConfig) { + t.Helper() + _, err := Instance.Client.ApplicationV2.CreateApplication(IAMOwnerCtx, &application.CreateApplicationRequest{ + ProjectId: projectID, + Name: integration.ApplicationName(), + ApplicationType: &application.CreateApplicationRequest_OidcConfiguration{ + OidcConfiguration: &application.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []application.OIDCResponseType{application.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []application.OIDCGrantType{application.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + ApplicationType: application.OIDCApplicationType_OIDC_APP_TYPE_NATIVE, + AuthMethodType: application.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + Version: application.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: application.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + Ios: ios, + Android: android, + }, + }, + }) + require.NoError(t, err) +} + +func assertEventuallyJSON(t *testing.T, url string, want any) { + t.Helper() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ct *assert.CollectT) { + resp, err := http.Get(url) + if !assert.NoError(ct, err) { + return + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + if !assert.Equal(ct, http.StatusOK, resp.StatusCode) { + return + } + assert.Contains(ct, resp.Header.Get("Content-Type"), "application/json") + assert.Equal(ct, "public, max-age=300", resp.Header.Get("Cache-Control")) + + body, err := io.ReadAll(resp.Body) + if !assert.NoError(ct, err) { + return + } + var got any + if !assert.NoError(ct, json.Unmarshal(body, &got)) { + return + } + assert.Equal(ct, want, got) + }, retryDuration, tick) +} + +func assertEventuallyJSONUnorderedApps(t *testing.T, url string, wantApps []string, wantAssetLinks []any) { + t.Helper() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ct *assert.CollectT) { + aasaResp, err := http.Get(url + well_known.AppleAppSiteAssociationPath) + if !assert.NoError(ct, err) { + return + } + defer aasaResp.Body.Close() + if !assert.Equal(ct, http.StatusOK, aasaResp.StatusCode) { + return + } + var aasa struct { + WebCredentials struct { + Apps []string `json:"apps"` + } `json:"webcredentials"` + } + if !assert.NoError(ct, json.NewDecoder(aasaResp.Body).Decode(&aasa)) { + return + } + assert.ElementsMatch(ct, wantApps, aasa.WebCredentials.Apps) + + assetResp, err := http.Get(url + well_known.AssetLinksPath) + if !assert.NoError(ct, err) { + return + } + defer assetResp.Body.Close() + if !assert.Equal(ct, http.StatusOK, assetResp.StatusCode) { + return + } + var got []any + if !assert.NoError(ct, json.NewDecoder(assetResp.Body).Decode(&got)) { + return + } + assert.ElementsMatch(ct, wantAssetLinks, got) + }, retryDuration, tick) +} diff --git a/internal/api/well_known/well_known.go b/internal/api/well_known/well_known.go new file mode 100644 index 000000000..15007d80c --- /dev/null +++ b/internal/api/well_known/well_known.go @@ -0,0 +1,196 @@ +package well_known + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/query" +) + +const ( + AppleAppSiteAssociationPath = "/.well-known/apple-app-site-association" + AssetLinksPath = "/.well-known/assetlinks.json" +) + +// HandlerPrefixes are registered with [api.API.RegisterHandlerPrefixes]. +var HandlerPrefixes = []string{ + AppleAppSiteAssociationPath, + AssetLinksPath, +} + +// Config holds runtime options for the well-known handlers. +type Config struct { + // AppLinksCacheControlMaxAge sets the Cache-Control max-age for + // apple-app-site-association and assetlinks.json responses. + // 0 sets Cache-Control: no-store. + AppLinksCacheControlMaxAge time.Duration +} + +type appLinkQuerier interface { + SearchOIDCAppLinkConfigs(ctx context.Context) ([]*query.OIDCAppLinkConfig, error) +} + +type Handler struct { + queries appLinkQuerier + cacheControlMaxAge time.Duration +} + +func NewHandler(queries appLinkQuerier, config Config) *Handler { + return &Handler{ + queries: queries, + cacheControlMaxAge: config.AppLinksCacheControlMaxAge, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AppleAppSiteAssociationPath: + h.serveAppleAppSiteAssociation(w, r) + case AssetLinksPath: + h.serveAssetLinks(w, r) + default: + http.NotFound(w, r) + } +} + +type appleAppSiteAssociation struct { + WebCredentials appleWebCredentials `json:"webcredentials"` +} + +type appleWebCredentials struct { + Apps []string `json:"apps"` +} + +type assetLink struct { + Relation []string `json:"relation"` + Target assetLinkTarget `json:"target"` +} + +type assetLinkTarget struct { + Namespace string `json:"namespace"` + PackageName string `json:"package_name"` + SHA256CertFingerprints []string `json:"sha256_cert_fingerprints"` +} + +func (h *Handler) serveAppleAppSiteAssociation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + configs, err := h.queries.SearchOIDCAppLinkConfigs(r.Context()) + if err != nil { + logging.Error(r.Context(), "unable to query OIDC app link configs for apple-app-site-association", "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + h.writeJSON(w, r, buildAppleAppSiteAssociation(configs)) +} + +func (h *Handler) serveAssetLinks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + configs, err := h.queries.SearchOIDCAppLinkConfigs(r.Context()) + if err != nil { + logging.Error(r.Context(), "unable to query OIDC app link configs for assetlinks.json", "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + h.writeJSON(w, r, buildAssetLinks(r.Context(), configs)) +} + +func buildAppleAppSiteAssociation(configs []*query.OIDCAppLinkConfig) appleAppSiteAssociation { + apps := make([]string, 0) + seen := make(map[string]struct{}) + for _, cfg := range configs { + if cfg.IOSTeamID == "" || cfg.IOSBundleID == "" { + continue + } + appID := cfg.IOSTeamID + "." + cfg.IOSBundleID + if _, ok := seen[appID]; ok { + continue + } + seen[appID] = struct{}{} + apps = append(apps, appID) + } + return appleAppSiteAssociation{ + WebCredentials: appleWebCredentials{Apps: apps}, + } +} + +func buildAssetLinks(ctx context.Context, configs []*query.OIDCAppLinkConfig) []assetLink { + links := make([]assetLink, 0) + for _, cfg := range configs { + if cfg.AndroidPackageName == "" || len(cfg.AndroidSHA256CertFingerprints) == 0 { + continue + } + links = append(links, assetLink{ + Relation: []string{"delegate_permission/common.get_login_creds"}, + Target: assetLinkTarget{ + Namespace: "android_app", + PackageName: cfg.AndroidPackageName, + SHA256CertFingerprints: normalizeSHA256Fingerprints(ctx, cfg.AndroidSHA256CertFingerprints), + }, + }) + } + return links +} + +// normalizeSHA256Fingerprints returns colon-separated uppercase hex fingerprints +// for assetlinks.json. Normalization is applied at serve time only so stored / +// client state can keep the format clients sent. +func normalizeSHA256Fingerprints(ctx context.Context, fps []string) []string { + out := make([]string, len(fps)) + for i, fp := range fps { + out[i] = normalizeSHA256Fingerprint(ctx, fp) + } + return out +} + +func normalizeSHA256Fingerprint(ctx context.Context, fp string) string { + if strings.Contains(fp, ":") { + return strings.ToUpper(fp) // already 32 valid pairs per API validation; only case may vary + } + raw, err := hex.DecodeString(fp) // guaranteed 64 hex chars + if err != nil || len(raw) != sha256.Size { + logging.Warn(ctx, "stored android sha256 fingerprint is not canonical hex; serving as-is", + "fingerprint", fp, "err", err) + return strings.ToUpper(fp) + } + parts := make([]string, len(raw)) + for i, b := range raw { + parts[i] = fmt.Sprintf("%02X", b) + } + return strings.Join(parts, ":") +} + +func (h *Handler) writeJSON(w http.ResponseWriter, r *http.Request, v any) { + w.Header().Set("Content-Type", "application/json") + h.setCacheControl(w) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + if err := json.NewEncoder(w).Encode(v); err != nil { + logging.Error(r.Context(), "unable to encode well-known response", "err", err) + } +} + +func (h *Handler) setCacheControl(w http.ResponseWriter) { + if h.cacheControlMaxAge <= 0 { + w.Header().Set(http_util.CacheControl, "no-store") + return + } + w.Header().Set(http_util.CacheControl, + fmt.Sprintf("public, max-age=%d", int(h.cacheControlMaxAge/time.Second)), + ) +} diff --git a/internal/api/well_known/well_known_test.go b/internal/api/well_known/well_known_test.go new file mode 100644 index 000000000..868b11645 --- /dev/null +++ b/internal/api/well_known/well_known_test.go @@ -0,0 +1,241 @@ +package well_known + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/query" +) + +func TestBuildAppleAppSiteAssociation(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + configs []*query.OIDCAppLinkConfig + wantApps []string + }{ + { + name: "empty", + configs: nil, + wantApps: []string{}, + }, + { + name: "skips incomplete ios", + configs: []*query.OIDCAppLinkConfig{ + {IOSTeamID: "TEAM"}, + {IOSBundleID: "com.example"}, + }, + wantApps: []string{}, + }, + { + name: "aggregates and dedupes", + configs: []*query.OIDCAppLinkConfig{ + {IOSTeamID: "TEAM1", IOSBundleID: "com.one"}, + {IOSTeamID: "TEAM2", IOSBundleID: "com.two"}, + {IOSTeamID: "TEAM1", IOSBundleID: "com.one"}, + {AndroidPackageName: "com.android", AndroidSHA256CertFingerprints: []string{"AA"}}, + }, + wantApps: []string{"TEAM1.com.one", "TEAM2.com.two"}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := buildAppleAppSiteAssociation(tc.configs) + assert.Equal(t, tc.wantApps, got.WebCredentials.Apps) + }) + } +} + +func TestBuildAssetLinks(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + configs []*query.OIDCAppLinkConfig + want []assetLink + }{ + { + name: "empty", + configs: nil, + want: []assetLink{}, + }, + { + name: "skips incomplete android", + configs: []*query.OIDCAppLinkConfig{ + {AndroidPackageName: "com.example"}, + {AndroidSHA256CertFingerprints: []string{"AA"}}, + }, + want: []assetLink{}, + }, + { + name: "aggregates and normalizes fingerprints", + configs: []*query.OIDCAppLinkConfig{ + { + IOSTeamID: "TEAM", + IOSBundleID: "com.ios", + AndroidPackageName: "com.one", + AndroidSHA256CertFingerprints: []string{"aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"}, + }, + { + AndroidPackageName: "com.two", + AndroidSHA256CertFingerprints: []string{ + "cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb", + "eeff00112233445566778899aabbccddeeff00112233445566778899aabbccdd", + }, + }, + }, + want: []assetLink{ + { + Relation: []string{"delegate_permission/common.get_login_creds"}, + Target: assetLinkTarget{ + Namespace: "android_app", + PackageName: "com.one", + SHA256CertFingerprints: []string{ + "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + }, + }, + }, + { + Relation: []string{"delegate_permission/common.get_login_creds"}, + Target: assetLinkTarget{ + Namespace: "android_app", + PackageName: "com.two", + SHA256CertFingerprints: []string{ + "CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB", + "EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD", + }, + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, buildAssetLinks(t.Context(), tc.configs)) + }) + } +} + +func TestNormalizeSHA256Fingerprint(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + in string + want string + }{ + { + name: "already canonical", + in: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + want: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + }, + { + name: "lowercase with colons", + in: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99", + want: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + }, + { + name: "lowercase without separators", + in: "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + want: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + }, + { + name: "invalid continuous hex uppercased", + in: "aabb", + want: "AABB", + }, + { + name: "colon form uppercased without reparse", + in: "aa:bb", + want: "AA:BB", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, normalizeSHA256Fingerprint(t.Context(), tc.in)) + }) + } +} + +type stubAppLinkQuerier struct { + configs []*query.OIDCAppLinkConfig + err error +} + +func (s stubAppLinkQuerier) SearchOIDCAppLinkConfigs(_ context.Context) ([]*query.OIDCAppLinkConfig, error) { + return s.configs, s.err +} + +func TestHandlerCacheControl(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + maxAge time.Duration + wantHeader string + }{ + { + name: "default max-age", + maxAge: 5 * time.Minute, + wantHeader: "public, max-age=300", + }, + { + name: "no-store when zero", + maxAge: 0, + wantHeader: "no-store", + }, + { + name: "no-store when negative", + maxAge: -time.Minute, + wantHeader: "no-store", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := NewHandler(stubAppLinkQuerier{}, Config{AppLinksCacheControlMaxAge: tc.maxAge}) + + for _, path := range []string{AppleAppSiteAssociationPath, AssetLinksPath} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, tc.wantHeader, rec.Header().Get(http_util.CacheControl), path) + assert.NotContains(t, rec.Header().Get(http_util.CacheControl), "stale-while-revalidate") + } + }) + } +} + +func TestHandlerHeadOmitsBody(t *testing.T) { + t.Parallel() + + h := NewHandler(stubAppLinkQuerier{}, Config{AppLinksCacheControlMaxAge: 5 * time.Minute}) + for _, path := range []string{AppleAppSiteAssociationPath, AssetLinksPath} { + t.Run(path, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodHead, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + assert.Equal(t, "public, max-age=300", rec.Header().Get(http_util.CacheControl)) + assert.Empty(t, rec.Body.Bytes()) + }) + } +} diff --git a/internal/command/instance_domain_test.go b/internal/command/instance_domain_test.go index 7b2a436ae..96e65a0ec 100644 --- a/internal/command/instance_domain_test.go +++ b/internal/command/instance_domain_test.go @@ -158,7 +158,10 @@ func TestCommandSide_AddInstanceDomain(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), ), ), expectPush( diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index b564f3c02..da48474cf 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -125,7 +125,10 @@ func oidcAppEvents(ctx context.Context, orgID, projectID, id, name, clientID str "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), } } @@ -468,7 +471,10 @@ func generatedDomainFilters(instanceID, orgID, projectID, appID, generatedDomain "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), ), expectFilter( func() eventstore.Event { diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index ae1b7392e..67caf4220 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -20,24 +20,28 @@ import ( type addOIDCApp struct { AddApp - Version domain.OIDCVersion - RedirectUris []string - ResponseTypes []domain.OIDCResponseType - GrantTypes []domain.OIDCGrantType - ApplicationType domain.OIDCApplicationType - AuthMethodType domain.OIDCAuthMethodType - PostLogoutRedirectUris []string - DevMode bool - AccessTokenType domain.OIDCTokenType - AccessTokenRoleAssertion bool - IDTokenRoleAssertion bool - IDTokenUserinfoAssertion bool - ClockSkew time.Duration - AdditionalOrigins []string - SkipSuccessPageForNativeApp bool - BackChannelLogoutURI string - LoginVersion domain.LoginVersion - LoginBaseURI string + Version domain.OIDCVersion + RedirectUris []string + ResponseTypes []domain.OIDCResponseType + GrantTypes []domain.OIDCGrantType + ApplicationType domain.OIDCApplicationType + AuthMethodType domain.OIDCAuthMethodType + PostLogoutRedirectUris []string + DevMode bool + AccessTokenType domain.OIDCTokenType + AccessTokenRoleAssertion bool + IDTokenRoleAssertion bool + IDTokenUserinfoAssertion bool + ClockSkew time.Duration + AdditionalOrigins []string + SkipSuccessPageForNativeApp bool + BackChannelLogoutURI string + LoginVersion domain.LoginVersion + LoginBaseURI string + IOSTeamID string + IOSBundleID string + AndroidPackageName string + AndroidSHA256CertFingerprints []string ClientID string ClientSecret string @@ -118,6 +122,10 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation { app.BackChannelLogoutURI, app.LoginVersion, app.LoginBaseURI, + app.IOSTeamID, + app.IOSBundleID, + app.AndroidPackageName, + app.AndroidSHA256CertFingerprints, ), }, nil }, nil @@ -251,6 +259,10 @@ func (c *Commands) pushOIDCApplication(ctx context.Context, addedApplication *OI backchannelLogoutURI, gu.Value(oidcApp.LoginVersion), strings.TrimSpace(gu.Value(oidcApp.LoginBaseURI)), + strings.TrimSpace(gu.Value(oidcApp.IOSTeamID)), + strings.TrimSpace(gu.Value(oidcApp.IOSBundleID)), + strings.TrimSpace(gu.Value(oidcApp.AndroidPackageName)), + trimStringSliceWhiteSpaces(oidcApp.AndroidSHA256CertFingerprints), )) events = append(events, extraEvents...) @@ -340,7 +352,7 @@ func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCA // UpdateDynamicOIDCClient). It reports whether anything actually changed. func (c *Commands) oidcApplicationChangeEvent(ctx context.Context, existingOIDC *OIDCApplicationWriteModel, oidc *domain.OIDCApp) (*project_repo.OIDCConfigChangedEvent, bool, error) { projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingOIDC.WriteModel) - var backChannelLogout, loginBaseURI *string + var backChannelLogout, loginBaseURI, iosTeamID, iosBundleID, androidPackageName *string if oidc.BackChannelLogoutURI != nil { bcl, err := c.validateBackchannelLogoutURI(oidc) if err != nil { @@ -352,6 +364,15 @@ func (c *Commands) oidcApplicationChangeEvent(ctx context.Context, existingOIDC if oidc.LoginBaseURI != nil { loginBaseURI = gu.Ptr(strings.TrimSpace(*oidc.LoginBaseURI)) } + if oidc.IOSTeamID != nil { + iosTeamID = gu.Ptr(strings.TrimSpace(*oidc.IOSTeamID)) + } + if oidc.IOSBundleID != nil { + iosBundleID = gu.Ptr(strings.TrimSpace(*oidc.IOSBundleID)) + } + if oidc.AndroidPackageName != nil { + androidPackageName = gu.Ptr(strings.TrimSpace(*oidc.AndroidPackageName)) + } return existingOIDC.NewChangedEvent( ctx, @@ -375,6 +396,10 @@ func (c *Commands) oidcApplicationChangeEvent(ctx context.Context, existingOIDC backChannelLogout, oidc.LoginVersion, loginBaseURI, + iosTeamID, + iosBundleID, + androidPackageName, + trimStringSliceWhiteSpaces(oidc.AndroidSHA256CertFingerprints), ) } diff --git a/internal/command/project_application_oidc_dcr_test.go b/internal/command/project_application_oidc_dcr_test.go index a84e11cd1..9fbe07546 100644 --- a/internal/command/project_application_oidc_dcr_test.go +++ b/internal/command/project_application_oidc_dcr_test.go @@ -234,7 +234,10 @@ func TestCommandSide_AddDynamicOIDCClient(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), // The registration access token (RFC 7592 §3) is persisted in the same // push as the application, so a registered client is never left // unmanageable. @@ -336,7 +339,10 @@ func TestCommandSide_AddDynamicOIDCClient(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), project.NewOIDCConfigRegistrationTokenChangedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, "app1", @@ -581,7 +587,11 @@ func TestCommandSide_UpdateDynamicOIDCClient(t *testing.T) { domain.OIDCApplicationTypeWeb, domain.OIDCAuthMethodTypeNone, []string{}, false, domain.OIDCTokenTypeBearer, false, false, false, 0, []string{}, false, "", - domain.LoginVersionUnspecified, "")), + domain.LoginVersionUnspecified, "", + "", + "", + "", + nil)), } } sameMetadata := &domain.OIDCApp{ diff --git a/internal/command/project_application_oidc_model.go b/internal/command/project_application_oidc_model.go index 45dcd2e8c..9f1dd6d5d 100644 --- a/internal/command/project_application_oidc_model.go +++ b/internal/command/project_application_oidc_model.go @@ -14,32 +14,36 @@ import ( type OIDCApplicationWriteModel struct { eventstore.WriteModel - AppID string - AppName string - ClientID string - HashedSecret string - ClientSecretString string - RedirectUris []string - ResponseTypes []domain.OIDCResponseType - GrantTypes []domain.OIDCGrantType - ApplicationType domain.OIDCApplicationType - AuthMethodType domain.OIDCAuthMethodType - PostLogoutRedirectUris []string - OIDCVersion domain.OIDCVersion - Compliance *domain.Compliance - DevMode bool - AccessTokenType domain.OIDCTokenType - AccessTokenRoleAssertion bool - IDTokenRoleAssertion bool - IDTokenUserinfoAssertion bool - ClockSkew time.Duration - State domain.AppState - AdditionalOrigins []string - SkipNativeAppSuccessPage bool - BackChannelLogoutURI string - LoginVersion domain.LoginVersion - LoginBaseURI string - oidc bool + AppID string + AppName string + ClientID string + HashedSecret string + ClientSecretString string + RedirectUris []string + ResponseTypes []domain.OIDCResponseType + GrantTypes []domain.OIDCGrantType + ApplicationType domain.OIDCApplicationType + AuthMethodType domain.OIDCAuthMethodType + PostLogoutRedirectUris []string + OIDCVersion domain.OIDCVersion + Compliance *domain.Compliance + DevMode bool + AccessTokenType domain.OIDCTokenType + AccessTokenRoleAssertion bool + IDTokenRoleAssertion bool + IDTokenUserinfoAssertion bool + ClockSkew time.Duration + State domain.AppState + AdditionalOrigins []string + SkipNativeAppSuccessPage bool + BackChannelLogoutURI string + LoginVersion domain.LoginVersion + LoginBaseURI string + IOSTeamID string + IOSBundleID string + AndroidPackageName string + AndroidSHA256CertFingerprints []string + oidc bool } func NewOIDCApplicationWriteModelWithAppID(projectID, appID, resourceOwner string) *OIDCApplicationWriteModel { @@ -140,6 +144,10 @@ func (wm *OIDCApplicationWriteModel) Reduce() error { wm.BackChannelLogoutURI = "" wm.LoginVersion = domain.LoginVersionUnspecified wm.LoginBaseURI = "" + wm.IOSTeamID = "" + wm.IOSBundleID = "" + wm.AndroidPackageName = "" + wm.AndroidSHA256CertFingerprints = nil wm.oidc = false wm.AppName = e.Name wm.State = domain.AppStateActive @@ -189,6 +197,10 @@ func (wm *OIDCApplicationWriteModel) Reduce() error { wm.BackChannelLogoutURI = "" wm.LoginVersion = domain.LoginVersionUnspecified wm.LoginBaseURI = "" + wm.IOSTeamID = "" + wm.IOSBundleID = "" + wm.AndroidPackageName = "" + wm.AndroidSHA256CertFingerprints = nil wm.oidc = false wm.State = domain.AppStateRemoved } @@ -218,6 +230,10 @@ func (wm *OIDCApplicationWriteModel) appendAddOIDCEvent(e *project.OIDCConfigAdd wm.BackChannelLogoutURI = e.BackChannelLogoutURI wm.LoginVersion = e.LoginVersion wm.LoginBaseURI = e.LoginBaseURI + wm.IOSTeamID = e.IOSTeamID + wm.IOSBundleID = e.IOSBundleID + wm.AndroidPackageName = e.AndroidPackageName + wm.AndroidSHA256CertFingerprints = e.AndroidSHA256CertFingerprints } func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfigChangedEvent) { @@ -275,6 +291,18 @@ func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfig if e.LoginBaseURI != nil { wm.LoginBaseURI = *e.LoginBaseURI } + if e.IOSTeamID != nil { + wm.IOSTeamID = *e.IOSTeamID + } + if e.IOSBundleID != nil { + wm.IOSBundleID = *e.IOSBundleID + } + if e.AndroidPackageName != nil { + wm.AndroidPackageName = *e.AndroidPackageName + } + if e.AndroidSHA256CertFingerprints != nil { + wm.AndroidSHA256CertFingerprints = *e.AndroidSHA256CertFingerprints + } } func (wm *OIDCApplicationWriteModel) Query() *eventstore.SearchQueryBuilder { @@ -320,6 +348,10 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( backChannelLogoutURI *string, loginVersion *domain.LoginVersion, loginBaseURI *string, + iosTeamID *string, + iosBundleID *string, + androidPackageName *string, + androidSHA256CertFingerprints []string, ) (*project.OIDCConfigChangedEvent, bool, error) { changes := make([]project.OIDCConfigChanges, 0) var err error @@ -378,6 +410,18 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { changes = append(changes, project.ChangeOIDCLoginBaseURI(*loginBaseURI)) } + if iosTeamID != nil && wm.IOSTeamID != *iosTeamID { + changes = append(changes, project.ChangeIOSTeamID(*iosTeamID)) + } + if iosBundleID != nil && wm.IOSBundleID != *iosBundleID { + changes = append(changes, project.ChangeIOSBundleID(*iosBundleID)) + } + if androidPackageName != nil && wm.AndroidPackageName != *androidPackageName { + changes = append(changes, project.ChangeAndroidPackageName(*androidPackageName)) + } + if androidSHA256CertFingerprints != nil && !slices.Equal(wm.AndroidSHA256CertFingerprints, androidSHA256CertFingerprints) { + changes = append(changes, project.ChangeAndroidSHA256CertFingerprints(androidSHA256CertFingerprints)) + } if len(changes) == 0 { return nil, false, nil diff --git a/internal/command/project_application_oidc_model_test.go b/internal/command/project_application_oidc_model_test.go new file mode 100644 index 000000000..628f0d5d3 --- /dev/null +++ b/internal/command/project_application_oidc_model_test.go @@ -0,0 +1,88 @@ +package command + +import ( + "context" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/project" +) + +func TestOIDCApplicationWriteModel_NewChangedEvent_AppLinks(t *testing.T) { + t.Parallel() + + agg := &project.NewAggregate("project-id", "org-id").Aggregate + base := func() *OIDCApplicationWriteModel { + return &OIDCApplicationWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "project-id", + ResourceOwner: "org-id", + }, + AppID: "app-id", + IOSTeamID: "OLDTEAM", + IOSBundleID: "com.old.app", + AndroidPackageName: "com.old.app", + AndroidSHA256CertFingerprints: []string{"AA:AA"}, + } + } + + t.Run("omit keeps existing", func(t *testing.T) { + t.Parallel() + wm := base() + event, hasChanged, err := wm.NewChangedEvent( + context.Background(), agg, "app-id", + nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, + ) + require.NoError(t, err) + assert.False(t, hasChanged) + assert.Nil(t, event) + }) + + t.Run("set changes fields", func(t *testing.T) { + t.Parallel() + wm := base() + event, hasChanged, err := wm.NewChangedEvent( + context.Background(), agg, "app-id", + nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + gu.Ptr("NEWTEAM"), + gu.Ptr("com.new.app"), + gu.Ptr("com.new.app"), + []string{"BB:BB"}, + ) + require.NoError(t, err) + require.True(t, hasChanged) + require.NotNil(t, event) + assert.Equal(t, gu.Ptr("NEWTEAM"), event.IOSTeamID) + assert.Equal(t, gu.Ptr("com.new.app"), event.IOSBundleID) + assert.Equal(t, gu.Ptr("com.new.app"), event.AndroidPackageName) + assert.Equal(t, &[]string{"BB:BB"}, event.AndroidSHA256CertFingerprints) + }) + + t.Run("clear with empty values", func(t *testing.T) { + t.Parallel() + wm := base() + event, hasChanged, err := wm.NewChangedEvent( + context.Background(), agg, "app-id", + nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + gu.Ptr(""), + gu.Ptr(""), + gu.Ptr(""), + []string{}, + ) + require.NoError(t, err) + require.True(t, hasChanged) + require.NotNil(t, event) + assert.Equal(t, gu.Ptr(""), event.IOSTeamID) + assert.Equal(t, gu.Ptr(""), event.IOSBundleID) + assert.Equal(t, gu.Ptr(""), event.AndroidPackageName) + assert.Equal(t, &[]string{}, event.AndroidSHA256CertFingerprints) + }) +} diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index be4ee73d7..4ca0c4a66 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -176,7 +176,10 @@ func TestAddOIDCApp(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), }, }, }, @@ -244,7 +247,10 @@ func TestAddOIDCApp(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), }, }, }, @@ -312,7 +318,10 @@ func TestAddOIDCApp(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), }, }, }, @@ -380,7 +389,10 @@ func TestAddOIDCApp(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), }, }, }, @@ -534,7 +546,10 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { "https://test.ch/backchannel", domain.LoginVersion2, "https://login.test.ch", - ), + "", + "", + "", + nil), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"), @@ -643,7 +658,10 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { "https://test.ch/backchannel", domain.LoginVersion2, "https://login.test.ch", - ), + "", + "", + "", + nil), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "client1"), @@ -753,7 +771,10 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { "https://test.ch/backchannel", domain.LoginVersion2, "https://login.test.ch", - ), + "", + "", + "", + nil), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "app1", "client1"), @@ -1072,7 +1093,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { "https://test.ch/backchannel", domain.LoginVersion2, "https://login.test.ch", - ), + "", + "", + "", + nil), ), ), expectFilter(), @@ -1147,7 +1171,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { "https://test.ch/backchannel", domain.LoginVersion2, "https://login.test.ch", - ), + "", + "", + "", + nil), ), ), expectFilter(), @@ -1222,7 +1249,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { "https://test.ch/backchannel", domain.LoginVersion1, "", - ), + "", + "", + "", + nil), ), ), expectFilter(), @@ -1316,7 +1346,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), ), ), expectFilter(), @@ -1376,7 +1409,10 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), ), ), expectFilter(), @@ -1531,7 +1567,10 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), ), ), expectPush( diff --git a/internal/command/project_application_secret_test.go b/internal/command/project_application_secret_test.go index 74426fa89..791ccaf54 100644 --- a/internal/command/project_application_secret_test.go +++ b/internal/command/project_application_secret_test.go @@ -118,7 +118,10 @@ func TestCommandSide_ChangeApplicationSecret(t *testing.T) { "", domain.LoginVersionUnspecified, "", - ), + "", + "", + "", + nil), ), ), expectPush( diff --git a/internal/command/project_converter.go b/internal/command/project_converter.go index e88a1cb75..597b96594 100644 --- a/internal/command/project_converter.go +++ b/internal/command/project_converter.go @@ -29,32 +29,45 @@ func projectGrantWriteModelToProjectGrant(writeModel *ProjectGrantWriteModel) *d func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.OIDCApp { return &domain.OIDCApp{ - ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), - AppID: writeModel.AppID, - AppName: writeModel.AppName, - State: writeModel.State, - ClientID: writeModel.ClientID, - RedirectUris: writeModel.RedirectUris, - ResponseTypes: writeModel.ResponseTypes, - GrantTypes: writeModel.GrantTypes, - ApplicationType: gu.Ptr(writeModel.ApplicationType), - AuthMethodType: gu.Ptr(writeModel.AuthMethodType), - PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris, - OIDCVersion: gu.Ptr(writeModel.OIDCVersion), - DevMode: gu.Ptr(writeModel.DevMode), - AccessTokenType: gu.Ptr(writeModel.AccessTokenType), - AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion), - IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion), - IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion), - ClockSkew: gu.Ptr(writeModel.ClockSkew), - AdditionalOrigins: writeModel.AdditionalOrigins, - SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage), - BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI), - LoginVersion: gu.Ptr(writeModel.LoginVersion), - LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), + ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), + AppID: writeModel.AppID, + AppName: writeModel.AppName, + State: writeModel.State, + ClientID: writeModel.ClientID, + RedirectUris: writeModel.RedirectUris, + ResponseTypes: writeModel.ResponseTypes, + GrantTypes: writeModel.GrantTypes, + ApplicationType: gu.Ptr(writeModel.ApplicationType), + AuthMethodType: gu.Ptr(writeModel.AuthMethodType), + PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris, + OIDCVersion: gu.Ptr(writeModel.OIDCVersion), + DevMode: gu.Ptr(writeModel.DevMode), + AccessTokenType: gu.Ptr(writeModel.AccessTokenType), + AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion), + IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion), + IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion), + ClockSkew: gu.Ptr(writeModel.ClockSkew), + AdditionalOrigins: writeModel.AdditionalOrigins, + SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage), + BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI), + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), + IOSTeamID: emptyStringPtr(writeModel.IOSTeamID), + IOSBundleID: emptyStringPtr(writeModel.IOSBundleID), + AndroidPackageName: emptyStringPtr(writeModel.AndroidPackageName), + AndroidSHA256CertFingerprints: writeModel.AndroidSHA256CertFingerprints, } } +// emptyStringPtr returns nil for empty strings so unset app-link fields stay +// distinguishable from explicitly set values in equality checks / APIs. +func emptyStringPtr(s string) *string { + if s == "" { + return nil + } + return gu.Ptr(s) +} + func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.SAMLApp { return &domain.SAMLApp{ ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 83746925f..2fce868bd 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/muhlemmer/gu" + http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) @@ -49,6 +51,14 @@ type OIDCApp struct { BackChannelLogoutURI *string LoginVersion *LoginVersion LoginBaseURI *string + // IOSTeamID and IOSBundleID are Associated Domains / passkey trust fields. + // Both must be set together, or both empty. Composed as TEAMID.BUNDLEID in AASA. + IOSTeamID *string + IOSBundleID *string + // AndroidPackageName and AndroidSHA256CertFingerprints are Digital Asset Links / + // passkey trust fields. Package name is required when fingerprints are non-empty. + AndroidPackageName *string + AndroidSHA256CertFingerprints []string State AppState } @@ -138,7 +148,7 @@ const ( ) func (a *OIDCApp) IsValid() bool { - if (a.ClockSkew != nil && (*a.ClockSkew > time.Second*5 || *a.ClockSkew < time.Second*0)) || !a.OriginsValid() { + if (a.ClockSkew != nil && (*a.ClockSkew > time.Second*5 || *a.ClockSkew < time.Second*0)) || !a.OriginsValid() || !a.AppLinkConfigValid() { return false } grantTypes := a.getRequiredGrantTypes() @@ -154,6 +164,19 @@ func (a *OIDCApp) IsValid() bool { return true } +// AppLinkConfigValid checks sibling rules for iOS/Android association fields. +// Nil and blank (after trim) are treated the same: both iOS fields must be set or +// both empty; Android package name is required whenever fingerprints are present. +func (a *OIDCApp) AppLinkConfigValid() bool { + teamID := strings.TrimSpace(gu.Value(a.IOSTeamID)) + bundleID := strings.TrimSpace(gu.Value(a.IOSBundleID)) + if (teamID == "") != (bundleID == "") { + return false + } + packageName := strings.TrimSpace(gu.Value(a.AndroidPackageName)) + return len(a.AndroidSHA256CertFingerprints) == 0 || packageName != "" +} + func (a *OIDCApp) OriginsValid() bool { for _, origin := range a.AdditionalOrigins { if !http_util.IsOrigin(strings.TrimSpace(origin)) { diff --git a/internal/domain/application_oidc_test.go b/internal/domain/application_oidc_test.go index 143239eea..d735d514f 100644 --- a/internal/domain/application_oidc_test.go +++ b/internal/domain/application_oidc_test.go @@ -669,3 +669,84 @@ func TestOIDCOriginAllowList(t *testing.T) { }) } } + +func TestOIDCApp_AppLinkConfigValid(t *testing.T) { + tests := []struct { + name string + app *OIDCApp + want bool + }{ + { + name: "empty config", + app: &OIDCApp{}, + want: true, + }, + { + name: "ios both set", + app: &OIDCApp{ + IOSTeamID: gu.Ptr("TEAMID"), + IOSBundleID: gu.Ptr("com.example.app"), + }, + want: true, + }, + { + name: "ios team only", + app: &OIDCApp{ + IOSTeamID: gu.Ptr("TEAMID"), + }, + want: false, + }, + { + name: "ios bundle only", + app: &OIDCApp{ + IOSBundleID: gu.Ptr("com.example.app"), + }, + want: false, + }, + { + name: "ios both empty strings", + app: &OIDCApp{ + IOSTeamID: gu.Ptr(""), + IOSBundleID: gu.Ptr(""), + }, + want: true, + }, + { + name: "android package only", + app: &OIDCApp{ + AndroidPackageName: gu.Ptr("com.example.app"), + }, + want: true, + }, + { + name: "android package and fingerprints", + app: &OIDCApp{ + AndroidPackageName: gu.Ptr("com.example.app"), + AndroidSHA256CertFingerprints: []string{"AA:BB"}, + }, + want: true, + }, + { + name: "android fingerprints without package", + app: &OIDCApp{ + AndroidSHA256CertFingerprints: []string{"AA:BB"}, + }, + want: false, + }, + { + name: "android fingerprints with empty package", + app: &OIDCApp{ + AndroidPackageName: gu.Ptr(" "), + AndroidSHA256CertFingerprints: []string{"AA:BB"}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.app.AppLinkConfigValid(); got != tt.want { + t.Errorf("AppLinkConfigValid() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/query/app.go b/internal/query/app.go index d07a7aa1a..eab69f14a 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -43,27 +43,31 @@ type App struct { } type OIDCApp struct { - RedirectURIs database.TextArray[string] - ResponseTypes database.NumberArray[domain.OIDCResponseType] - GrantTypes database.NumberArray[domain.OIDCGrantType] - AppType domain.OIDCApplicationType - ClientID string - AuthMethodType domain.OIDCAuthMethodType - PostLogoutRedirectURIs database.TextArray[string] - Version domain.OIDCVersion - ComplianceProblems database.TextArray[string] - IsDevMode bool - AccessTokenType domain.OIDCTokenType - AssertAccessTokenRole bool - AssertIDTokenRole bool - AssertIDTokenUserinfo bool - ClockSkew time.Duration - AdditionalOrigins database.TextArray[string] - AllowedOrigins database.TextArray[string] - SkipNativeAppSuccessPage bool - BackChannelLogoutURI string - LoginVersion domain.LoginVersion - LoginBaseURI *string + RedirectURIs database.TextArray[string] + ResponseTypes database.NumberArray[domain.OIDCResponseType] + GrantTypes database.NumberArray[domain.OIDCGrantType] + AppType domain.OIDCApplicationType + ClientID string + AuthMethodType domain.OIDCAuthMethodType + PostLogoutRedirectURIs database.TextArray[string] + Version domain.OIDCVersion + ComplianceProblems database.TextArray[string] + IsDevMode bool + AccessTokenType domain.OIDCTokenType + AssertAccessTokenRole bool + AssertIDTokenRole bool + AssertIDTokenUserinfo bool + ClockSkew time.Duration + AdditionalOrigins database.TextArray[string] + AllowedOrigins database.TextArray[string] + SkipNativeAppSuccessPage bool + BackChannelLogoutURI string + LoginVersion domain.LoginVersion + LoginBaseURI *string + IOSTeamID string + IOSBundleID string + AndroidPackageName string + AndroidSHA256CertFingerprints database.TextArray[string] } type SAMLApp struct { @@ -278,6 +282,22 @@ var ( name: projection.AppOIDCConfigColumnLoginBaseURI, table: appOIDCConfigsTable, } + AppOIDCConfigColumnIOSTeamID = Column{ + name: projection.AppOIDCConfigColumnIOSTeamID, + table: appOIDCConfigsTable, + } + AppOIDCConfigColumnIOSBundleID = Column{ + name: projection.AppOIDCConfigColumnIOSBundleID, + table: appOIDCConfigsTable, + } + AppOIDCConfigColumnAndroidPackageName = Column{ + name: projection.AppOIDCConfigColumnAndroidPackageName, + table: appOIDCConfigsTable, + } + AppOIDCConfigColumnAndroidSHA256CertFingerprints = Column{ + name: projection.AppOIDCConfigColumnAndroidSHA256CertFingerprints, + table: appOIDCConfigsTable, + } ) func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bool, projectID, appID string) (app *App, err error) { @@ -725,6 +745,10 @@ func prepareAppQuery(activeOnly bool) (sq.SelectBuilder, func(*sql.Row) (*App, e AppOIDCConfigColumnBackChannelLogoutURI.identifier(), AppOIDCConfigColumnLoginVersion.identifier(), AppOIDCConfigColumnLoginBaseURI.identifier(), + AppOIDCConfigColumnIOSTeamID.identifier(), + AppOIDCConfigColumnIOSBundleID.identifier(), + AppOIDCConfigColumnAndroidPackageName.identifier(), + AppOIDCConfigColumnAndroidSHA256CertFingerprints.identifier(), AppSAMLConfigColumnAppID.identifier(), AppSAMLConfigColumnEntityID.identifier(), @@ -794,6 +818,10 @@ func scanApp(row *sql.Row) (*App, error) { &oidcConfig.backChannelLogoutURI, &oidcConfig.loginVersion, &oidcConfig.loginBaseURI, + &oidcConfig.iosTeamID, + &oidcConfig.iosBundleID, + &oidcConfig.androidPackageName, + &oidcConfig.androidSHA256CertFingerprints, &samlConfig.appID, &samlConfig.entityID, @@ -848,6 +876,10 @@ func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) { AppOIDCConfigColumnBackChannelLogoutURI.identifier(), AppOIDCConfigColumnLoginVersion.identifier(), AppOIDCConfigColumnLoginBaseURI.identifier(), + AppOIDCConfigColumnIOSTeamID.identifier(), + AppOIDCConfigColumnIOSBundleID.identifier(), + AppOIDCConfigColumnAndroidPackageName.identifier(), + AppOIDCConfigColumnAndroidSHA256CertFingerprints.identifier(), ).From(appsTable.identifier()). Join(join(AppOIDCConfigColumnAppID, AppColumnID)). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) { @@ -887,6 +919,10 @@ func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) { &oidcConfig.backChannelLogoutURI, &oidcConfig.loginVersion, &oidcConfig.loginBaseURI, + &oidcConfig.iosTeamID, + &oidcConfig.iosBundleID, + &oidcConfig.androidPackageName, + &oidcConfig.androidSHA256CertFingerprints, ) if err != nil { @@ -1004,6 +1040,10 @@ func prepareAppsQuery() (sq.SelectBuilder, func(*sql.Rows) (*Apps, error)) { AppOIDCConfigColumnBackChannelLogoutURI.identifier(), AppOIDCConfigColumnLoginVersion.identifier(), AppOIDCConfigColumnLoginBaseURI.identifier(), + AppOIDCConfigColumnIOSTeamID.identifier(), + AppOIDCConfigColumnIOSBundleID.identifier(), + AppOIDCConfigColumnAndroidPackageName.identifier(), + AppOIDCConfigColumnAndroidSHA256CertFingerprints.identifier(), AppSAMLConfigColumnAppID.identifier(), AppSAMLConfigColumnEntityID.identifier(), @@ -1061,6 +1101,10 @@ func prepareAppsQuery() (sq.SelectBuilder, func(*sql.Rows) (*Apps, error)) { &oidcConfig.backChannelLogoutURI, &oidcConfig.loginVersion, &oidcConfig.loginBaseURI, + &oidcConfig.iosTeamID, + &oidcConfig.iosBundleID, + &oidcConfig.androidPackageName, + &oidcConfig.androidSHA256CertFingerprints, &samlConfig.appID, &samlConfig.entityID, @@ -1148,26 +1192,30 @@ func prepareLoginVersionBySAMLAppID() (sq.SelectBuilder, func(*sql.Row) (domain. } type sqlOIDCConfig struct { - appID sql.NullString - version sql.NullInt32 - clientID sql.NullString - redirectUris database.TextArray[string] - applicationType sql.NullInt16 - authMethodType sql.NullInt16 - postLogoutRedirectUris database.TextArray[string] - devMode sql.NullBool - accessTokenType sql.NullInt16 - accessTokenRoleAssertion sql.NullBool - iDTokenRoleAssertion sql.NullBool - iDTokenUserinfoAssertion sql.NullBool - clockSkew sql.NullInt64 - additionalOrigins database.TextArray[string] - responseTypes database.NumberArray[domain.OIDCResponseType] - grantTypes database.NumberArray[domain.OIDCGrantType] - skipNativeAppSuccessPage sql.NullBool - backChannelLogoutURI sql.NullString - loginVersion sql.NullInt16 - loginBaseURI sql.NullString + appID sql.NullString + version sql.NullInt32 + clientID sql.NullString + redirectUris database.TextArray[string] + applicationType sql.NullInt16 + authMethodType sql.NullInt16 + postLogoutRedirectUris database.TextArray[string] + devMode sql.NullBool + accessTokenType sql.NullInt16 + accessTokenRoleAssertion sql.NullBool + iDTokenRoleAssertion sql.NullBool + iDTokenUserinfoAssertion sql.NullBool + clockSkew sql.NullInt64 + additionalOrigins database.TextArray[string] + responseTypes database.NumberArray[domain.OIDCResponseType] + grantTypes database.NumberArray[domain.OIDCGrantType] + skipNativeAppSuccessPage sql.NullBool + backChannelLogoutURI sql.NullString + loginVersion sql.NullInt16 + loginBaseURI sql.NullString + iosTeamID sql.NullString + iosBundleID sql.NullString + androidPackageName sql.NullString + androidSHA256CertFingerprints database.TextArray[string] } func (c sqlOIDCConfig) set(app *App) { @@ -1175,24 +1223,28 @@ func (c sqlOIDCConfig) set(app *App) { return } app.OIDCConfig = &OIDCApp{ - Version: domain.OIDCVersion(c.version.Int32), - ClientID: c.clientID.String, - RedirectURIs: c.redirectUris, - AppType: domain.OIDCApplicationType(c.applicationType.Int16), - AuthMethodType: domain.OIDCAuthMethodType(c.authMethodType.Int16), - PostLogoutRedirectURIs: c.postLogoutRedirectUris, - IsDevMode: c.devMode.Bool, - AccessTokenType: domain.OIDCTokenType(c.accessTokenType.Int16), - AssertAccessTokenRole: c.accessTokenRoleAssertion.Bool, - AssertIDTokenRole: c.iDTokenRoleAssertion.Bool, - AssertIDTokenUserinfo: c.iDTokenUserinfoAssertion.Bool, - ClockSkew: time.Duration(c.clockSkew.Int64), - AdditionalOrigins: c.additionalOrigins, - ResponseTypes: c.responseTypes, - GrantTypes: c.grantTypes, - SkipNativeAppSuccessPage: c.skipNativeAppSuccessPage.Bool, - BackChannelLogoutURI: c.backChannelLogoutURI.String, - LoginVersion: domain.LoginVersion(c.loginVersion.Int16), + Version: domain.OIDCVersion(c.version.Int32), + ClientID: c.clientID.String, + RedirectURIs: c.redirectUris, + AppType: domain.OIDCApplicationType(c.applicationType.Int16), + AuthMethodType: domain.OIDCAuthMethodType(c.authMethodType.Int16), + PostLogoutRedirectURIs: c.postLogoutRedirectUris, + IsDevMode: c.devMode.Bool, + AccessTokenType: domain.OIDCTokenType(c.accessTokenType.Int16), + AssertAccessTokenRole: c.accessTokenRoleAssertion.Bool, + AssertIDTokenRole: c.iDTokenRoleAssertion.Bool, + AssertIDTokenUserinfo: c.iDTokenUserinfoAssertion.Bool, + ClockSkew: time.Duration(c.clockSkew.Int64), + AdditionalOrigins: c.additionalOrigins, + ResponseTypes: c.responseTypes, + GrantTypes: c.grantTypes, + SkipNativeAppSuccessPage: c.skipNativeAppSuccessPage.Bool, + BackChannelLogoutURI: c.backChannelLogoutURI.String, + LoginVersion: domain.LoginVersion(c.loginVersion.Int16), + IOSTeamID: c.iosTeamID.String, + IOSBundleID: c.iosBundleID.String, + AndroidPackageName: c.androidPackageName.String, + AndroidSHA256CertFingerprints: c.androidSHA256CertFingerprints, } if c.loginBaseURI.Valid { app.OIDCConfig.LoginBaseURI = &c.loginBaseURI.String diff --git a/internal/query/app_link.go b/internal/query/app_link.go new file mode 100644 index 000000000..05f28fa0e --- /dev/null +++ b/internal/query/app_link.go @@ -0,0 +1,100 @@ +package query + +import ( + "context" + "database/sql" + "strings" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// OIDCAppLinkConfig holds iOS/Android association fields for one active OIDC app. +type OIDCAppLinkConfig struct { + AppID string + IOSTeamID string + IOSBundleID string + AndroidPackageName string + AndroidSHA256CertFingerprints []string +} + +// SearchOIDCAppLinkConfigs returns association fields for active OIDC apps in the +// current instance that have at least one iOS or Android app-link field set. +func (q *Queries) SearchOIDCAppLinkConfigs(ctx context.Context) (configs []*OIDCAppLinkConfig, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareOIDCAppLinkConfigsQuery() + iosTeamIDCol := AppOIDCConfigColumnIOSTeamID.identifier() + iosBundleIDCol := AppOIDCConfigColumnIOSBundleID.identifier() + androidPackageCol := AppOIDCConfigColumnAndroidPackageName.identifier() + stmt, args, err := query.Where(sq.And{ + sq.Eq{ + AppOIDCConfigColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppColumnState.identifier(): domain.AppStateActive, + }, + sq.Or{ + sq.Expr("COALESCE(" + iosTeamIDCol + ", '') <> ''"), + sq.Expr("COALESCE(" + iosBundleIDCol + ", '') <> ''"), + sq.Expr("COALESCE(" + androidPackageCol + ", '') <> ''"), + }, + }).OrderBy(AppOIDCConfigColumnAppID.identifier()).ToSql() + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "QUERY-ApLn1", "Errors.Query.InvalidRequest") + } + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + configs, err = scan(rows) + return err + }, stmt, args...) + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-ApLn2", "Errors.Internal") + } + return configs, nil +} + +func prepareOIDCAppLinkConfigsQuery() (sq.SelectBuilder, func(*sql.Rows) ([]*OIDCAppLinkConfig, error)) { + return sq.Select( + AppOIDCConfigColumnAppID.identifier(), + AppOIDCConfigColumnIOSTeamID.identifier(), + AppOIDCConfigColumnIOSBundleID.identifier(), + AppOIDCConfigColumnAndroidPackageName.identifier(), + AppOIDCConfigColumnAndroidSHA256CertFingerprints.identifier(), + ).From(appOIDCConfigsTable.identifier()). + Join(join(AppColumnID, AppOIDCConfigColumnAppID)). + PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) ([]*OIDCAppLinkConfig, error) { + configs := make([]*OIDCAppLinkConfig, 0) + for rows.Next() { + var ( + appID sql.NullString + iosTeamID sql.NullString + iosBundleID sql.NullString + androidPackage sql.NullString + fingerprints database.TextArray[string] + ) + if err := rows.Scan( + &appID, + &iosTeamID, + &iosBundleID, + &androidPackage, + &fingerprints, + ); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-ApLn3", "Errors.Internal") + } + configs = append(configs, &OIDCAppLinkConfig{ + AppID: appID.String, + IOSTeamID: strings.TrimSpace(iosTeamID.String), + IOSBundleID: strings.TrimSpace(iosBundleID.String), + AndroidPackageName: strings.TrimSpace(androidPackage.String), + AndroidSHA256CertFingerprints: fingerprints, + }) + } + return configs, nil + } +} diff --git a/internal/query/app_test.go b/internal/query/app_test.go index c24060a60..880ea5bf8 100644 --- a/internal/query/app_test.go +++ b/internal/query/app_test.go @@ -51,6 +51,10 @@ var ( ` projections.apps7_oidc_configs.back_channel_logout_uri,` + ` projections.apps7_oidc_configs.login_version,` + ` projections.apps7_oidc_configs.login_base_uri,` + + ` projections.apps7_oidc_configs.ios_team_id,` + + ` projections.apps7_oidc_configs.ios_bundle_id,` + + ` projections.apps7_oidc_configs.android_package_name,` + + ` projections.apps7_oidc_configs.android_sha256_cert_fingerprints,` + //saml config ` projections.apps7_saml_configs.app_id,` + ` projections.apps7_saml_configs.entity_id,` + @@ -99,6 +103,10 @@ var ( ` projections.apps7_oidc_configs.back_channel_logout_uri,` + ` projections.apps7_oidc_configs.login_version,` + ` projections.apps7_oidc_configs.login_base_uri,` + + ` projections.apps7_oidc_configs.ios_team_id,` + + ` projections.apps7_oidc_configs.ios_bundle_id,` + + ` projections.apps7_oidc_configs.android_package_name,` + + ` projections.apps7_oidc_configs.android_sha256_cert_fingerprints,` + //saml config ` projections.apps7_saml_configs.app_id,` + ` projections.apps7_saml_configs.entity_id,` + @@ -172,6 +180,10 @@ var ( "back_channel_logout_uri", "login_version", "login_base_uri", + "ios_team_id", + "ios_bundle_id", + "android_package_name", + "android_sha256_cert_fingerprints", //saml config "app_id", "entity_id", @@ -248,6 +260,10 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -319,6 +335,10 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -393,6 +413,10 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config "app-id", "https://test.com/saml/metadata", @@ -469,6 +493,10 @@ func Test_AppsPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -563,6 +591,10 @@ func Test_AppsPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -657,6 +689,10 @@ func Test_AppsPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -751,6 +787,10 @@ func Test_AppsPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -845,6 +885,10 @@ func Test_AppsPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -939,6 +983,10 @@ func Test_AppsPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1033,6 +1081,10 @@ func Test_AppsPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersion2, "https://login.ch/", + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1075,6 +1127,10 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1117,6 +1173,10 @@ func Test_AppsPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config "saml-app-id", "https://test.com/saml/metadata", @@ -1302,6 +1362,10 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1367,6 +1431,10 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1437,6 +1505,10 @@ func Test_AppPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1526,6 +1598,10 @@ func Test_AppPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1615,6 +1691,10 @@ func Test_AppPrepare(t *testing.T) { nil, nil, nil, + nil, + nil, + nil, + nil, // saml config "app-id", "https://test.com/saml/metadata", @@ -1688,6 +1768,10 @@ func Test_AppPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1777,6 +1861,10 @@ func Test_AppPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1866,6 +1954,10 @@ func Test_AppPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, @@ -1955,6 +2047,10 @@ func Test_AppPrepare(t *testing.T) { "back.channel.logout.ch", domain.LoginVersionUnspecified, nil, + nil, + nil, + nil, + nil, // saml config nil, nil, diff --git a/internal/query/projection/app.go b/internal/query/projection/app.go index 0105b5566..09ba28faf 100644 --- a/internal/query/projection/app.go +++ b/internal/query/projection/app.go @@ -38,30 +38,34 @@ const ( AppAPIConfigColumnClientSecret = "client_secret" AppAPIConfigColumnAuthMethod = "auth_method" - appOIDCTableSuffix = "oidc_configs" - AppOIDCConfigColumnAppID = "app_id" - AppOIDCConfigColumnInstanceID = "instance_id" - AppOIDCConfigColumnVersion = "version" - AppOIDCConfigColumnClientID = "client_id" - AppOIDCConfigColumnClientSecret = "client_secret" - AppOIDCConfigColumnRedirectUris = "redirect_uris" - AppOIDCConfigColumnResponseTypes = "response_types" - AppOIDCConfigColumnGrantTypes = "grant_types" - AppOIDCConfigColumnApplicationType = "application_type" - AppOIDCConfigColumnAuthMethodType = "auth_method_type" - AppOIDCConfigColumnPostLogoutRedirectUris = "post_logout_redirect_uris" - AppOIDCConfigColumnDevMode = "is_dev_mode" - AppOIDCConfigColumnAccessTokenType = "access_token_type" - AppOIDCConfigColumnAccessTokenRoleAssertion = "access_token_role_assertion" - AppOIDCConfigColumnIDTokenRoleAssertion = "id_token_role_assertion" - AppOIDCConfigColumnIDTokenUserinfoAssertion = "id_token_userinfo_assertion" - AppOIDCConfigColumnClockSkew = "clock_skew" - AppOIDCConfigColumnAdditionalOrigins = "additional_origins" - AppOIDCConfigColumnSkipNativeAppSuccessPage = "skip_native_app_success_page" - AppOIDCConfigColumnBackChannelLogoutURI = "back_channel_logout_uri" - AppOIDCConfigColumnLoginVersion = "login_version" - AppOIDCConfigColumnLoginBaseURI = "login_base_uri" - AppOIDCConfigColumnRegistrationToken = "registration_token" + appOIDCTableSuffix = "oidc_configs" + AppOIDCConfigColumnAppID = "app_id" + AppOIDCConfigColumnInstanceID = "instance_id" + AppOIDCConfigColumnVersion = "version" + AppOIDCConfigColumnClientID = "client_id" + AppOIDCConfigColumnClientSecret = "client_secret" + AppOIDCConfigColumnRedirectUris = "redirect_uris" + AppOIDCConfigColumnResponseTypes = "response_types" + AppOIDCConfigColumnGrantTypes = "grant_types" + AppOIDCConfigColumnApplicationType = "application_type" + AppOIDCConfigColumnAuthMethodType = "auth_method_type" + AppOIDCConfigColumnPostLogoutRedirectUris = "post_logout_redirect_uris" + AppOIDCConfigColumnDevMode = "is_dev_mode" + AppOIDCConfigColumnAccessTokenType = "access_token_type" + AppOIDCConfigColumnAccessTokenRoleAssertion = "access_token_role_assertion" + AppOIDCConfigColumnIDTokenRoleAssertion = "id_token_role_assertion" + AppOIDCConfigColumnIDTokenUserinfoAssertion = "id_token_userinfo_assertion" + AppOIDCConfigColumnClockSkew = "clock_skew" + AppOIDCConfigColumnAdditionalOrigins = "additional_origins" + AppOIDCConfigColumnSkipNativeAppSuccessPage = "skip_native_app_success_page" + AppOIDCConfigColumnBackChannelLogoutURI = "back_channel_logout_uri" + AppOIDCConfigColumnLoginVersion = "login_version" + AppOIDCConfigColumnLoginBaseURI = "login_base_uri" + AppOIDCConfigColumnIOSTeamID = "ios_team_id" + AppOIDCConfigColumnIOSBundleID = "ios_bundle_id" + AppOIDCConfigColumnAndroidPackageName = "android_package_name" + AppOIDCConfigColumnAndroidSHA256CertFingerprints = "android_sha256_cert_fingerprints" + AppOIDCConfigColumnRegistrationToken = "registration_token" appSAMLTableSuffix = "saml_configs" AppSAMLConfigColumnAppID = "app_id" @@ -134,6 +138,10 @@ func (*appProjection) Init() *old_handler.Check { handler.NewColumn(AppOIDCConfigColumnBackChannelLogoutURI, handler.ColumnTypeText, handler.Nullable()), handler.NewColumn(AppOIDCConfigColumnLoginVersion, handler.ColumnTypeEnum, handler.Nullable()), handler.NewColumn(AppOIDCConfigColumnLoginBaseURI, handler.ColumnTypeText, handler.Nullable()), + handler.NewColumn(AppOIDCConfigColumnIOSTeamID, handler.ColumnTypeText, handler.Nullable()), + handler.NewColumn(AppOIDCConfigColumnIOSBundleID, handler.ColumnTypeText, handler.Nullable()), + handler.NewColumn(AppOIDCConfigColumnAndroidPackageName, handler.ColumnTypeText, handler.Nullable()), + handler.NewColumn(AppOIDCConfigColumnAndroidSHA256CertFingerprints, handler.ColumnTypeTextArray, handler.Nullable()), handler.NewColumn(AppOIDCConfigColumnRegistrationToken, handler.ColumnTypeText, handler.Nullable()), }, handler.NewPrimaryKey(AppOIDCConfigColumnInstanceID, AppOIDCConfigColumnAppID), @@ -519,6 +527,10 @@ func (p *appProjection) reduceOIDCConfigAdded(event eventstore.Event) (*handler. handler.NewCol(AppOIDCConfigColumnBackChannelLogoutURI, e.BackChannelLogoutURI), handler.NewCol(AppOIDCConfigColumnLoginVersion, e.LoginVersion), handler.NewCol(AppOIDCConfigColumnLoginBaseURI, e.LoginBaseURI), + handler.NewCol(AppOIDCConfigColumnIOSTeamID, e.IOSTeamID), + handler.NewCol(AppOIDCConfigColumnIOSBundleID, e.IOSBundleID), + handler.NewCol(AppOIDCConfigColumnAndroidPackageName, e.AndroidPackageName), + handler.NewCol(AppOIDCConfigColumnAndroidSHA256CertFingerprints, database.TextArray[string](e.AndroidSHA256CertFingerprints)), }, handler.WithTableSuffix(appOIDCTableSuffix), ), @@ -596,6 +608,18 @@ func (p *appProjection) reduceOIDCConfigChanged(event eventstore.Event) (*handle if e.LoginBaseURI != nil { cols = append(cols, handler.NewCol(AppOIDCConfigColumnLoginBaseURI, *e.LoginBaseURI)) } + if e.IOSTeamID != nil { + cols = append(cols, handler.NewCol(AppOIDCConfigColumnIOSTeamID, *e.IOSTeamID)) + } + if e.IOSBundleID != nil { + cols = append(cols, handler.NewCol(AppOIDCConfigColumnIOSBundleID, *e.IOSBundleID)) + } + if e.AndroidPackageName != nil { + cols = append(cols, handler.NewCol(AppOIDCConfigColumnAndroidPackageName, *e.AndroidPackageName)) + } + if e.AndroidSHA256CertFingerprints != nil { + cols = append(cols, handler.NewCol(AppOIDCConfigColumnAndroidSHA256CertFingerprints, database.TextArray[string](*e.AndroidSHA256CertFingerprints))) + } if len(cols) == 0 { return handler.NewNoOpStatement(e), nil diff --git a/internal/query/projection/app_test.go b/internal/query/projection/app_test.go index c693d7c3c..c50906eff 100644 --- a/internal/query/projection/app_test.go +++ b/internal/query/projection/app_test.go @@ -572,7 +572,7 @@ func TestAppProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri, login_version, login_base_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)", + expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri, login_version, login_base_uri, ios_team_id, ios_bundle_id, android_package_name, android_sha256_cert_fingerprints) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", expectedArgs: []interface{}{ "app-id", "instance-id", @@ -596,6 +596,10 @@ func TestAppProjection_reduces(t *testing.T) { "back.channel.one.ch", domain.LoginVersion2, "https://login.ch/", + "", + "", + "", + database.TextArray[string](nil), }, }, { @@ -650,7 +654,7 @@ func TestAppProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri, login_version, login_base_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)", + expectedStmt: "INSERT INTO projections.apps7_oidc_configs (app_id, instance_id, version, client_id, client_secret, redirect_uris, response_types, grant_types, application_type, auth_method_type, post_logout_redirect_uris, is_dev_mode, access_token_type, access_token_role_assertion, id_token_role_assertion, id_token_userinfo_assertion, clock_skew, additional_origins, skip_native_app_success_page, back_channel_logout_uri, login_version, login_base_uri, ios_team_id, ios_bundle_id, android_package_name, android_sha256_cert_fingerprints) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", expectedArgs: []interface{}{ "app-id", "instance-id", @@ -674,6 +678,10 @@ func TestAppProjection_reduces(t *testing.T) { "back.channel.one.ch", domain.LoginVersion2, "https://login.ch/", + "", + "", + "", + database.TextArray[string](nil), }, }, { @@ -761,6 +769,52 @@ func TestAppProjection_reduces(t *testing.T) { }, }, }, + { + name: "project reduceOIDCConfigChanged app links", + args: args{ + event: getEvent( + testEvent( + project.OIDCConfigChangedType, + project.AggregateType, + []byte(`{ + "appId": "app-id", + "iosTeamId": "TEAMID", + "iosBundleId": "com.example.app", + "androidPackageName": "com.example.app", + "androidSha256CertFingerprints": ["AA:BB:CC"] + }`), + ), project.OIDCConfigChangedEventMapper), + }, + reduce: (&appProjection{}).reduceOIDCConfigChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("project"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.apps7_oidc_configs SET (ios_team_id, ios_bundle_id, android_package_name, android_sha256_cert_fingerprints) = ($1, $2, $3, $4) WHERE (app_id = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + "TEAMID", + "com.example.app", + "com.example.app", + database.TextArray[string]{"AA:BB:CC"}, + "app-id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.apps7 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "app-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "project reduceOIDCConfigChanged noop", args: args{ diff --git a/internal/repository/project/oidc_config.go b/internal/repository/project/oidc_config.go index 5df0fa1ec..98d4881ee 100644 --- a/internal/repository/project/oidc_config.go +++ b/internal/repository/project/oidc_config.go @@ -2,6 +2,7 @@ package project import ( "context" + "slices" "time" "github.com/zitadel/zitadel/internal/crypto" @@ -31,23 +32,27 @@ type OIDCConfigAddedEvent struct { ClientSecret *crypto.CryptoValue `json:"clientSecret,omitempty"` HashedSecret string `json:"hashedSecret,omitempty"` - RedirectUris []string `json:"redirectUris,omitempty"` - ResponseTypes []domain.OIDCResponseType `json:"responseTypes,omitempty"` - GrantTypes []domain.OIDCGrantType `json:"grantTypes,omitempty"` - ApplicationType domain.OIDCApplicationType `json:"applicationType,omitempty"` - AuthMethodType domain.OIDCAuthMethodType `json:"authMethodType,omitempty"` - PostLogoutRedirectUris []string `json:"postLogoutRedirectUris,omitempty"` - DevMode bool `json:"devMode,omitempty"` - AccessTokenType domain.OIDCTokenType `json:"accessTokenType,omitempty"` - AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion,omitempty"` - IDTokenRoleAssertion bool `json:"idTokenRoleAssertion,omitempty"` - IDTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion,omitempty"` - ClockSkew time.Duration `json:"clockSkew,omitempty"` - AdditionalOrigins []string `json:"additionalOrigins,omitempty"` - SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage,omitempty"` - BackChannelLogoutURI string `json:"backChannelLogoutURI,omitempty"` - LoginVersion domain.LoginVersion `json:"loginVersion,omitempty"` - LoginBaseURI string `json:"loginBaseURI,omitempty"` + RedirectUris []string `json:"redirectUris,omitempty"` + ResponseTypes []domain.OIDCResponseType `json:"responseTypes,omitempty"` + GrantTypes []domain.OIDCGrantType `json:"grantTypes,omitempty"` + ApplicationType domain.OIDCApplicationType `json:"applicationType,omitempty"` + AuthMethodType domain.OIDCAuthMethodType `json:"authMethodType,omitempty"` + PostLogoutRedirectUris []string `json:"postLogoutRedirectUris,omitempty"` + DevMode bool `json:"devMode,omitempty"` + AccessTokenType domain.OIDCTokenType `json:"accessTokenType,omitempty"` + AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion,omitempty"` + IDTokenRoleAssertion bool `json:"idTokenRoleAssertion,omitempty"` + IDTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion,omitempty"` + ClockSkew time.Duration `json:"clockSkew,omitempty"` + AdditionalOrigins []string `json:"additionalOrigins,omitempty"` + SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage,omitempty"` + BackChannelLogoutURI string `json:"backChannelLogoutURI,omitempty"` + LoginVersion domain.LoginVersion `json:"loginVersion,omitempty"` + LoginBaseURI string `json:"loginBaseURI,omitempty"` + IOSTeamID string `json:"iosTeamId,omitempty"` + IOSBundleID string `json:"iosBundleId,omitempty"` + AndroidPackageName string `json:"androidPackageName,omitempty"` + AndroidSHA256CertFingerprints []string `json:"androidSha256CertFingerprints,omitempty"` } func (e *OIDCConfigAddedEvent) Payload() interface{} { @@ -82,6 +87,10 @@ func NewOIDCConfigAddedEvent( backChannelLogoutURI string, loginVersion domain.LoginVersion, loginBaseURI string, + iosTeamID string, + iosBundleID string, + androidPackageName string, + androidSHA256CertFingerprints []string, ) *OIDCConfigAddedEvent { return &OIDCConfigAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -89,27 +98,31 @@ func NewOIDCConfigAddedEvent( aggregate, OIDCConfigAddedType, ), - Version: version, - AppID: appID, - ClientID: clientID, - HashedSecret: hashedSecret, - RedirectUris: redirectUris, - ResponseTypes: responseTypes, - GrantTypes: grantTypes, - ApplicationType: applicationType, - AuthMethodType: authMethodType, - PostLogoutRedirectUris: postLogoutRedirectUris, - DevMode: devMode, - AccessTokenType: accessTokenType, - AccessTokenRoleAssertion: accessTokenRoleAssertion, - IDTokenRoleAssertion: idTokenRoleAssertion, - IDTokenUserinfoAssertion: idTokenUserinfoAssertion, - ClockSkew: clockSkew, - AdditionalOrigins: additionalOrigins, - SkipNativeAppSuccessPage: skipNativeAppSuccessPage, - BackChannelLogoutURI: backChannelLogoutURI, - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + Version: version, + AppID: appID, + ClientID: clientID, + HashedSecret: hashedSecret, + RedirectUris: redirectUris, + ResponseTypes: responseTypes, + GrantTypes: grantTypes, + ApplicationType: applicationType, + AuthMethodType: authMethodType, + PostLogoutRedirectUris: postLogoutRedirectUris, + DevMode: devMode, + AccessTokenType: accessTokenType, + AccessTokenRoleAssertion: accessTokenRoleAssertion, + IDTokenRoleAssertion: idTokenRoleAssertion, + IDTokenUserinfoAssertion: idTokenUserinfoAssertion, + ClockSkew: clockSkew, + AdditionalOrigins: additionalOrigins, + SkipNativeAppSuccessPage: skipNativeAppSuccessPage, + BackChannelLogoutURI: backChannelLogoutURI, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + IOSTeamID: iosTeamID, + IOSBundleID: iosBundleID, + AndroidPackageName: androidPackageName, + AndroidSHA256CertFingerprints: androidSHA256CertFingerprints, } } @@ -204,7 +217,19 @@ func (e *OIDCConfigAddedEvent) Validate(cmd eventstore.Command) bool { if e.LoginVersion != c.LoginVersion { return false } - return e.LoginBaseURI == c.LoginBaseURI + if e.LoginBaseURI != c.LoginBaseURI { + return false + } + if e.IOSTeamID != c.IOSTeamID { + return false + } + if e.IOSBundleID != c.IOSBundleID { + return false + } + if e.AndroidPackageName != c.AndroidPackageName { + return false + } + return slices.Equal(e.AndroidSHA256CertFingerprints, c.AndroidSHA256CertFingerprints) } func OIDCConfigAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { @@ -223,25 +248,29 @@ func OIDCConfigAddedEventMapper(event eventstore.Event) (eventstore.Event, error type OIDCConfigChangedEvent struct { eventstore.BaseEvent `json:"-"` - Version *domain.OIDCVersion `json:"oidcVersion,omitempty"` - AppID string `json:"appId"` - RedirectUris *[]string `json:"redirectUris,omitempty"` - ResponseTypes *[]domain.OIDCResponseType `json:"responseTypes,omitempty"` - GrantTypes *[]domain.OIDCGrantType `json:"grantTypes,omitempty"` - ApplicationType *domain.OIDCApplicationType `json:"applicationType,omitempty"` - AuthMethodType *domain.OIDCAuthMethodType `json:"authMethodType,omitempty"` - PostLogoutRedirectUris *[]string `json:"postLogoutRedirectUris,omitempty"` - DevMode *bool `json:"devMode,omitempty"` - AccessTokenType *domain.OIDCTokenType `json:"accessTokenType,omitempty"` - AccessTokenRoleAssertion *bool `json:"accessTokenRoleAssertion,omitempty"` - IDTokenRoleAssertion *bool `json:"idTokenRoleAssertion,omitempty"` - IDTokenUserinfoAssertion *bool `json:"idTokenUserinfoAssertion,omitempty"` - ClockSkew *time.Duration `json:"clockSkew,omitempty"` - AdditionalOrigins *[]string `json:"additionalOrigins,omitempty"` - SkipNativeAppSuccessPage *bool `json:"skipNativeAppSuccessPage,omitempty"` - BackChannelLogoutURI *string `json:"backChannelLogoutURI,omitempty"` - LoginVersion *domain.LoginVersion `json:"loginVersion,omitempty"` - LoginBaseURI *string `json:"loginBaseURI,omitempty"` + Version *domain.OIDCVersion `json:"oidcVersion,omitempty"` + AppID string `json:"appId"` + RedirectUris *[]string `json:"redirectUris,omitempty"` + ResponseTypes *[]domain.OIDCResponseType `json:"responseTypes,omitempty"` + GrantTypes *[]domain.OIDCGrantType `json:"grantTypes,omitempty"` + ApplicationType *domain.OIDCApplicationType `json:"applicationType,omitempty"` + AuthMethodType *domain.OIDCAuthMethodType `json:"authMethodType,omitempty"` + PostLogoutRedirectUris *[]string `json:"postLogoutRedirectUris,omitempty"` + DevMode *bool `json:"devMode,omitempty"` + AccessTokenType *domain.OIDCTokenType `json:"accessTokenType,omitempty"` + AccessTokenRoleAssertion *bool `json:"accessTokenRoleAssertion,omitempty"` + IDTokenRoleAssertion *bool `json:"idTokenRoleAssertion,omitempty"` + IDTokenUserinfoAssertion *bool `json:"idTokenUserinfoAssertion,omitempty"` + ClockSkew *time.Duration `json:"clockSkew,omitempty"` + AdditionalOrigins *[]string `json:"additionalOrigins,omitempty"` + SkipNativeAppSuccessPage *bool `json:"skipNativeAppSuccessPage,omitempty"` + BackChannelLogoutURI *string `json:"backChannelLogoutURI,omitempty"` + LoginVersion *domain.LoginVersion `json:"loginVersion,omitempty"` + LoginBaseURI *string `json:"loginBaseURI,omitempty"` + IOSTeamID *string `json:"iosTeamId,omitempty"` + IOSBundleID *string `json:"iosBundleId,omitempty"` + AndroidPackageName *string `json:"androidPackageName,omitempty"` + AndroidSHA256CertFingerprints *[]string `json:"androidSha256CertFingerprints,omitempty"` } func (e *OIDCConfigChangedEvent) Payload() interface{} { @@ -398,6 +427,34 @@ func ChangeOIDCLoginBaseURI(loginBaseURI string) func(event *OIDCConfigChangedEv } } +func ChangeIOSTeamID(iosTeamID string) func(event *OIDCConfigChangedEvent) { + return func(e *OIDCConfigChangedEvent) { + e.IOSTeamID = &iosTeamID + } +} + +func ChangeIOSBundleID(iosBundleID string) func(event *OIDCConfigChangedEvent) { + return func(e *OIDCConfigChangedEvent) { + e.IOSBundleID = &iosBundleID + } +} + +func ChangeAndroidPackageName(androidPackageName string) func(event *OIDCConfigChangedEvent) { + return func(e *OIDCConfigChangedEvent) { + e.AndroidPackageName = &androidPackageName + } +} + +func ChangeAndroidSHA256CertFingerprints(fingerprints []string) func(event *OIDCConfigChangedEvent) { + return func(e *OIDCConfigChangedEvent) { + if fingerprints == nil { + // explicitly set them to empty so we can differentiate "not set" in the event in case of no changes + fingerprints = make([]string, 0) + } + e.AndroidSHA256CertFingerprints = &fingerprints + } +} + func OIDCConfigChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { e := &OIDCConfigChangedEvent{ BaseEvent: *eventstore.BaseEventFromRepo(event), diff --git a/proto/zitadel/app.proto b/proto/zitadel/app.proto index 08359e376..c4486c17a 100644 --- a/proto/zitadel/app.proto +++ b/proto/zitadel/app.proto @@ -179,6 +179,57 @@ message OIDCConfig { description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; } ]; + IOSAppLinkConfig ios = 23 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + } + ]; + AndroidAppLinkConfig android = 24 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + } + ]; +} + +message IOSAppLinkConfig { + string team_id = 1 [ + (validate.rules).string = {ignore_empty: true, len: 10, pattern: "^[A-Z0-9]+$"}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ABCDE12345\""; + description: "Apple Team ID (App ID prefix), exactly 10 alphanumeric characters, e.g. ABCDE12345. Combined with bundle_id as TEAMID.BUNDLEID in AASA webcredentials.apps. Not the full Apple App ID by itself. Empty clears the value on update."; + } + ]; + string bundle_id = 2 [ + (validate.rules).string = {ignore_empty: true, min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"com.example.app\""; + description: "iOS Bundle ID (CFBundleIdentifier), e.g. com.example.app. Combined with team_id for AASA webcredentials.apps. Do not paste TEAMID.bundle.id here. Empty clears the value on update."; + } + ]; +} + +message AndroidAppLinkConfig { + string package_name = 1 [ + (validate.rules).string = {ignore_empty: true, min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"com.example.app\""; + description: "Android package name (applicationId). Used in assetlinks.json target.package_name. Empty clears the value on update."; + } + ]; + repeated string sha256_cert_fingerprints = 2 [ + (validate.rules).repeated = { + max_items: 20, + items: { + string: { + pattern: "^([0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){31}|[0-9A-Fa-f]{64})$"; + } + } + }, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99\"]"; + description: "SHA-256 signing certificate fingerprints for Digital Asset Links (passkey / get_login_creds). 64 hex chars, optionally colon-separated pairs. At most 20 entries. Include debug and release as needed."; + } + ]; } enum OIDCResponseType { diff --git a/proto/zitadel/application/v2/application_service.proto b/proto/zitadel/application/v2/application_service.proto index 533d00963..a84d62345 100644 --- a/proto/zitadel/application/v2/application_service.proto +++ b/proto/zitadel/application/v2/application_service.proto @@ -341,6 +341,19 @@ message CreateOIDCApplicationRequest { // hosted on any other domain. // If unset, the login UI is chosen by the instance default. LoginVersion login_version = 17; + + // IOS is iOS Associated Domains / passkey trust config. + // Served in /.well-known/apple-app-site-association as webcredentials.apps entry + // "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). + // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; + // changes can take time to take effect. + IOSAppLinkConfig ios = 18; + + // Android is Android Digital Asset Links / passkey trust config. + // Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. + // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; + // changes can take time to take effect. + AndroidAppLinkConfig android = 19; } message CreateOIDCApplicationResponse { @@ -578,6 +591,21 @@ message UpdateOIDCApplicationConfigurationRequest { // hosted on any other domain. // If unset, the login UI is chosen by the instance default. optional LoginVersion login_version = 17; + + // IOS is iOS Associated Domains / passkey trust config. + // Served in /.well-known/apple-app-site-association as webcredentials.apps entry + // "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). + // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; + // changes can take time to take effect. + // If not set, the iOS config will not be changed. + optional IOSAppLinkConfig ios = 18; + + // Android is Android Digital Asset Links / passkey trust config. + // Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. + // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; + // changes can take time to take effect. + // If not set, the Android config will not be changed. + optional AndroidAppLinkConfig android = 19; } message UpdateAPIApplicationConfigurationRequest { diff --git a/proto/zitadel/application/v2/oidc.proto b/proto/zitadel/application/v2/oidc.proto index 9469e3f3a..95f7b4355 100644 --- a/proto/zitadel/application/v2/oidc.proto +++ b/proto/zitadel/application/v2/oidc.proto @@ -4,6 +4,7 @@ package zitadel.application.v2; import "google/protobuf/duration.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; import "zitadel/application/v2/login.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/application/v2;application"; @@ -157,4 +158,55 @@ message OIDCConfiguration { // hosted on any other domain. // If unset, the login UI is chosen by the instance default. LoginVersion login_version = 21; + + // IOS is iOS Associated Domains / passkey trust config. + // Served in /.well-known/apple-app-site-association as webcredentials.apps entry + // "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). + // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; + // changes can take time to take effect. + IOSAppLinkConfig ios = 22; + + // Android is Android Digital Asset Links / passkey trust config. + // Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. + // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; + // changes can take time to take effect. + AndroidAppLinkConfig android = 23; +} + +// IOSAppLinkConfig is iOS Associated Domains / passkey trust config. +// Served in /.well-known/apple-app-site-association as webcredentials.apps entry +// "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). +// That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; +// changes can take time to take effect. +message IOSAppLinkConfig { + // TeamID is the Apple Developer Team ID (exactly 10 alphanumeric characters), e.g. ABCDE12345. + // From Apple Developer account membership — not the Bundle ID. + // Empty clears the value on update. + string team_id = 1 [(validate.rules).string = {ignore_empty: true, len: 10, pattern: "^[A-Z0-9]+$"}]; + + // BundleID is the Xcode / CFBundleIdentifier, e.g. com.example.app. + // Not the full Apple App ID; do not include the Team ID prefix here. + // Empty clears the value on update. + string bundle_id = 2 [(validate.rules).string = {ignore_empty: true, min_len: 1, max_len: 200}]; +} + +// AndroidAppLinkConfig is Android Digital Asset Links / passkey trust config. +// Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. +// That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; +// changes can take time to take effect. +message AndroidAppLinkConfig { + // PackageName is the Android applicationId / package name from the app manifest. + // Empty clears the value on update. + string package_name = 1 [(validate.rules).string = {ignore_empty: true, min_len: 1, max_len: 200}]; + + // SHA256CertFingerprints are signing certificate fingerprints (64 hex chars, optional colons). + // Include debug and release fingerprints as needed. At most 20 entries. + repeated string sha256_cert_fingerprints = 2 [(validate.rules).repeated = { + max_items: 20, + items: { + string: { + pattern: "^([0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){31}|[0-9A-Fa-f]{64})$"; + } + } + }]; } diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 1bd4c7f81..a416a3912 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -10382,6 +10382,16 @@ message AddOIDCAppRequest { description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; } ]; + zitadel.app.v1.IOSAppLinkConfig ios = 20 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + } + ]; + zitadel.app.v1.AndroidAppLinkConfig android = 21 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + } + ]; } message AddOIDCAppResponse { @@ -10573,6 +10583,16 @@ message UpdateOIDCAppConfigRequest { description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; } ]; + zitadel.app.v1.IOSAppLinkConfig ios = 19 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + } + ]; + zitadel.app.v1.AndroidAppLinkConfig android = 20 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + } + ]; } message UpdateOIDCAppConfigResponse {