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
+115 -58
View File
@@ -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),