Files
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

476 lines
15 KiB
Go

package domain
import (
"net/netip"
"net/url"
"slices"
"strings"
"time"
"github.com/muhlemmer/gu"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
)
const (
httpScheme = "http://"
httpsScheme = "https://"
localhostHostname = "localhost"
)
type OIDCApp struct {
models.ObjectRoot
AppID string
AppName string
ClientID string
EncodedHash string
ClientSecretString string
// RegistrationAccessToken is the plain registration access token (RFC 7592 §3) of a
// dynamically registered client. It is only populated transiently on registration and
// rotation so the registration endpoint can return it to the client; it is never
// persisted (only its hash is, see project.OIDCConfigRegistrationTokenChangedEvent).
RegistrationAccessToken string
RedirectUris []string
ResponseTypes []OIDCResponseType
GrantTypes []OIDCGrantType
ApplicationType *OIDCApplicationType
AuthMethodType *OIDCAuthMethodType
PostLogoutRedirectUris []string
OIDCVersion *OIDCVersion
Compliance *Compliance
DevMode *bool
AccessTokenType *OIDCTokenType
AccessTokenRoleAssertion *bool
IDTokenRoleAssertion *bool
IDTokenUserinfoAssertion *bool
ClockSkew *time.Duration
AdditionalOrigins []string
SkipNativeAppSuccessPage *bool
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
}
func (a *OIDCApp) GetApplicationName() string {
return a.AppName
}
func (a *OIDCApp) GetState() AppState {
return a.State
}
func (a *OIDCApp) setClientID(clientID string) {
a.ClientID = clientID
}
func (a *OIDCApp) setClientSecret(encodedHash string) {
a.EncodedHash = encodedHash
}
func (a *OIDCApp) requiresClientSecret() bool {
return a.AuthMethodType != nil && (*a.AuthMethodType == OIDCAuthMethodTypeBasic || *a.AuthMethodType == OIDCAuthMethodTypePost)
}
type OIDCVersion int32
const (
OIDCVersionV1 OIDCVersion = iota
)
type OIDCResponseType int32
const (
OIDCResponseTypeUnspecified OIDCResponseType = iota - 1 // Negative offset not to break existing configs.
OIDCResponseTypeCode
OIDCResponseTypeIDToken
OIDCResponseTypeIDTokenToken
)
//go:generate enumer -type OIDCResponseMode -transform snake -trimprefix OIDCResponseMode
type OIDCResponseMode int
const (
OIDCResponseModeUnspecified OIDCResponseMode = iota
OIDCResponseModeQuery
OIDCResponseModeFragment
OIDCResponseModeFormPost
)
type OIDCGrantType int32
const (
OIDCGrantTypeAuthorizationCode OIDCGrantType = iota
OIDCGrantTypeImplicit
OIDCGrantTypeRefreshToken
OIDCGrantTypeDeviceCode
OIDCGrantTypeTokenExchange
)
type OIDCApplicationType int32
const (
OIDCApplicationTypeWeb OIDCApplicationType = iota
OIDCApplicationTypeUserAgent
OIDCApplicationTypeNative
)
type OIDCAuthMethodType int32
const (
OIDCAuthMethodTypeBasic OIDCAuthMethodType = iota
OIDCAuthMethodTypePost
OIDCAuthMethodTypeNone
OIDCAuthMethodTypePrivateKeyJWT
)
type Compliance struct {
NoneCompliant bool
Problems []string
}
type OIDCTokenType int32
const (
OIDCTokenTypeBearer OIDCTokenType = iota
OIDCTokenTypeJWT
)
func (a *OIDCApp) IsValid() bool {
if (a.ClockSkew != nil && (*a.ClockSkew > time.Second*5 || *a.ClockSkew < time.Second*0)) || !a.OriginsValid() || !a.AppLinkConfigValid() {
return false
}
grantTypes := a.getRequiredGrantTypes()
if len(grantTypes) == 0 {
return false
}
for _, grantType := range grantTypes {
ok := containsOIDCGrantType(a.GrantTypes, grantType)
if !ok {
return false
}
}
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)) {
return false
}
}
return true
}
func ContainsRequiredGrantTypes(responseTypes []OIDCResponseType, grantTypes []OIDCGrantType) bool {
required := RequiredOIDCGrantTypes(responseTypes, grantTypes)
return ContainsOIDCGrantTypes(required, grantTypes)
}
func RequiredOIDCGrantTypes(responseTypes []OIDCResponseType, grantTypesSet []OIDCGrantType) (grantTypes []OIDCGrantType) {
var implicit bool
for _, r := range responseTypes {
switch r {
case OIDCResponseTypeCode:
// #5684 when "Device Code" is selected, "Authorization Code" is no longer a hard requirement
if !containsOIDCGrantType(grantTypesSet, OIDCGrantTypeDeviceCode) {
grantTypes = append(grantTypes, OIDCGrantTypeAuthorizationCode)
} else {
grantTypes = append(grantTypes, OIDCGrantTypeDeviceCode)
}
case OIDCResponseTypeIDToken, OIDCResponseTypeIDTokenToken:
if !implicit {
implicit = true
grantTypes = append(grantTypes, OIDCGrantTypeImplicit)
}
}
}
return grantTypes
}
func (a *OIDCApp) getRequiredGrantTypes() []OIDCGrantType {
return RequiredOIDCGrantTypes(a.ResponseTypes, a.GrantTypes)
}
func ContainsOIDCGrantTypes(shouldContain, list []OIDCGrantType) bool {
for _, should := range shouldContain {
if !containsOIDCGrantType(list, should) {
return false
}
}
return true
}
func containsOIDCGrantType(grantTypes []OIDCGrantType, grantType OIDCGrantType) bool {
return slices.Contains(grantTypes, grantType)
}
func (a *OIDCApp) FillCompliance() {
a.Compliance = GetOIDCCompliance(a.OIDCVersion, a.ApplicationType, a.GrantTypes, a.ResponseTypes, a.AuthMethodType, a.RedirectUris)
}
func GetOIDCCompliance(version *OIDCVersion, appType *OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance {
if version != nil && *version == OIDCVersionV1 {
return GetOIDCV1Compliance(appType, grantTypes, authMethod, redirectUris)
}
return &Compliance{
NoneCompliant: true,
Problems: []string{"Application.OIDC.UnsupportedVersion"},
}
}
func GetOIDCV1Compliance(appType *OIDCApplicationType, grantTypes []OIDCGrantType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance {
compliance := &Compliance{NoneCompliant: false}
checkGrantTypesCombination(compliance, grantTypes)
checkRedirectURIs(compliance, grantTypes, appType, redirectUris)
checkApplicationType(compliance, appType, authMethod)
if compliance.NoneCompliant {
compliance.Problems = append([]string{"Application.OIDC.V1.NotCompliant"}, compliance.Problems...)
}
return compliance
}
func checkGrantTypesCombination(compliance *Compliance, grantTypes []OIDCGrantType) {
if !containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) && containsOIDCGrantType(grantTypes, OIDCGrantTypeRefreshToken) && !containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode) {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.GrantType.Refresh.NoAuthCode")
}
}
func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType *OIDCApplicationType, redirectUris []string) {
// See #5684 for OIDCGrantTypeDeviceCode and redirectUris further explanation
if len(redirectUris) == 0 && (!containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) || (containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) && containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode))) {
compliance.NoneCompliant = true
compliance.Problems = append([]string{"Application.OIDC.V1.NoRedirectUris"}, compliance.Problems...)
}
if containsOIDCGrantType(grantTypes, OIDCGrantTypeImplicit) && containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode) {
CheckRedirectUrisImplicitAndCode(compliance, appType, redirectUris)
} else {
if containsOIDCGrantType(grantTypes, OIDCGrantTypeImplicit) {
CheckRedirectUrisImplicit(compliance, appType, redirectUris)
}
if containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode) {
CheckRedirectUrisCode(compliance, appType, redirectUris)
}
}
}
func checkApplicationType(compliance *Compliance, appType *OIDCApplicationType, authMethod *OIDCAuthMethodType) {
if appType != nil {
switch *appType {
case OIDCApplicationTypeNative:
GetOIDCV1NativeApplicationCompliance(compliance, authMethod)
case OIDCApplicationTypeUserAgent:
GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod)
case OIDCApplicationTypeWeb:
return
}
}
if compliance.NoneCompliant {
compliance.Problems = append([]string{"Application.OIDC.V1.NotCompliant"}, compliance.Problems...)
}
}
func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) {
if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.AuthMethodType.NotNone")
}
}
func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) {
if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.UserAgent.AuthMethodType.NotNone")
}
}
func CheckRedirectUrisCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) {
if urlsAreHttps(redirectUris) {
return
}
if urlContainsPrefix(redirectUris, httpScheme) {
if appType != nil && *appType == OIDCApplicationTypeUserAgent {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb")
}
if appType != nil && *appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost")
}
}
if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.CustomOnlyForNative")
}
}
func CheckRedirectUrisImplicit(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) {
if urlsAreHttps(redirectUris) {
return
}
if containsCustom(redirectUris) {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed")
}
if urlContainsPrefix(redirectUris, httpScheme) {
if appType != nil && *appType == OIDCApplicationTypeNative {
if !onlyLocalhostIsHttp(redirectUris) {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost")
}
return
}
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.HttpNotAllowed")
}
}
func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) {
if urlsAreHttps(redirectUris) {
return
}
if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed")
}
if urlContainsPrefix(redirectUris, httpScheme) {
if appType != nil && *appType == OIDCApplicationTypeUserAgent {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb")
}
if !onlyLocalhostIsHttp(redirectUris) && appType != nil && *appType == OIDCApplicationTypeNative {
compliance.NoneCompliant = true
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost")
}
}
if !compliance.NoneCompliant {
compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.NotAllCombinationsAreAllowed")
}
}
func urlsAreHttps(uris []string) bool {
for _, uri := range uris {
if !strings.HasPrefix(uri, httpsScheme) {
return false
}
}
return true
}
func urlContainsPrefix(uris []string, prefix string) bool {
for _, uri := range uris {
if strings.HasPrefix(uri, prefix) {
return true
}
}
return false
}
func containsCustom(uris []string) bool {
for _, uri := range uris {
if !strings.HasPrefix(uri, httpScheme) && !strings.HasPrefix(uri, httpsScheme) {
return true
}
}
return false
}
// OIDCRedirectURIsRequireNative reports whether the given redirect URIs can only be
// compliant for a native application, because they use a custom scheme. It lets a caller
// that has to infer the application type (see the dynamic client registration endpoint)
// stay in agreement with CheckRedirectUrisCode and CheckRedirectUrisImplicitAndCode, which
// reserve custom schemes for native applications.
func OIDCRedirectURIsRequireNative(redirectURIs []string) bool {
return containsCustom(redirectURIs)
}
// onlyLocalhostIsHttp returns true if:
//
// - input string slice is empty
// - all parseable URIs with scheme `http` in the string slice are localhost/loopback URIs (in all possible forms)
//
// It will return false if:
// - any of the input URIs cannot be parsed
// - any of the parseable input URIs with scheme `http` is not localhost/loopback
func onlyLocalhostIsHttp(uris []string) bool {
for _, uri := range uris {
url, err := url.ParseRequestURI(uri)
if err != nil {
return false
}
if url.Scheme == "http" {
hostname := url.Hostname()
if hostname == localhostHostname {
continue
}
address, err := netip.ParseAddr(hostname)
if err != nil {
return false
}
if address.IsLoopback() {
continue
}
return false
}
}
return true
}
func OIDCOriginAllowList(redirectURIs, additionalOrigins []string) ([]string, error) {
allowList := make([]string, 0)
for _, redirect := range redirectURIs {
origin, err := http_util.GetOriginFromURLString(redirect)
if err != nil {
return nil, err
}
if !http_util.IsOriginAllowed(allowList, origin) {
allowList = append(allowList, origin)
}
}
for _, origin := range additionalOrigins {
if !http_util.IsOriginAllowed(allowList, origin) {
allowList = append(allowList, origin)
}
}
return allowList, nil
}