Files
zitadel/internal/query/app_link.go
T
318bfc36d7 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]>
2026-08-12 08:04:27 +02:00

101 lines
3.5 KiB
Go

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
}
}