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
+111 -59
View File
@@ -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