feat: native app links for passkeys (#12580)

# Which Problems Are Solved

- Native iOS/Android passkeys need OS trust files on the ZITADEL domain;
without them, Associated Domains / App Links verification fails.
- Operators could not configure iOS Team ID + Bundle ID or Android
package name + SHA-256 fingerprints on OIDC apps.
- `/.well-known/apple-app-site-association` and
`/.well-known/assetlinks.json` were not served from application config.

# How the Problems Are Solved

- Add iOS/Android app-link fields on OIDC app create/update (Application
API v2 + Management), with validation.
- Persist and project those fields; query active app-link configs
instance-wide.
- Serve AASA (`webcredentials`) and Digital Asset Links
(`get_login_creds`) from well-known paths, with configurable
`Cache-Control` and fingerprint normalization at serve time.
- Console UI to edit the fields, with links to the well-known endpoints.
- Operator docs for configuration, endpoints, caching, and verification.

# Additional Changes

- Document on API fields that well-known responses may be HTTP-cached
and platform verifiers may delay propagation.
- Runtime config: `WellKnown.AppLinksCacheControlMaxAge` (default `5m`;
`0` → `no-store`).

# Additional Context

- Closes #12497
- Implemented and reviewed as stack:
  - #12531 API contract
  - #12532 storage wiring
  - #12536 well-known endpoints
  - #12537 console
  - #12547 docs

---------

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Livio Spring
2026-08-12 08:04:27 +02:00
committed by GitHub
co-authored by Cursor
parent 2bd42e8fc4
commit 318bfc36d7
68 changed files with 3227 additions and 467 deletions
+51
View File
@@ -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 {
@@ -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 {
+52
View File
@@ -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})$";
}
}
}];
}
+20
View File
@@ -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 {