Files
zitadel/internal/command/project_application_oidc_model.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

439 lines
14 KiB
Go

package command
import (
"context"
"slices"
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/project"
)
type OIDCApplicationWriteModel struct {
eventstore.WriteModel
AppID string
AppName string
ClientID string
HashedSecret string
ClientSecretString string
RedirectUris []string
ResponseTypes []domain.OIDCResponseType
GrantTypes []domain.OIDCGrantType
ApplicationType domain.OIDCApplicationType
AuthMethodType domain.OIDCAuthMethodType
PostLogoutRedirectUris []string
OIDCVersion domain.OIDCVersion
Compliance *domain.Compliance
DevMode bool
AccessTokenType domain.OIDCTokenType
AccessTokenRoleAssertion bool
IDTokenRoleAssertion bool
IDTokenUserinfoAssertion bool
ClockSkew time.Duration
State domain.AppState
AdditionalOrigins []string
SkipNativeAppSuccessPage bool
BackChannelLogoutURI string
LoginVersion domain.LoginVersion
LoginBaseURI string
IOSTeamID string
IOSBundleID string
AndroidPackageName string
AndroidSHA256CertFingerprints []string
oidc bool
}
func NewOIDCApplicationWriteModelWithAppID(projectID, appID, resourceOwner string) *OIDCApplicationWriteModel {
return &OIDCApplicationWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: projectID,
ResourceOwner: resourceOwner,
},
AppID: appID,
}
}
func NewOIDCApplicationWriteModel(projectID, resourceOwner string) *OIDCApplicationWriteModel {
return &OIDCApplicationWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: projectID,
ResourceOwner: resourceOwner,
},
}
}
func (wm *OIDCApplicationWriteModel) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *project.ApplicationAddedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationChangedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationDeactivatedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationReactivatedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.ApplicationRemovedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.OIDCConfigAddedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.OIDCConfigChangedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.OIDCConfigSecretChangedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
case *project.OIDCConfigSecretHashUpdatedEvent:
if e.AppID != wm.AppID {
continue
}
wm.WriteModel.AppendEvents(e)
default:
wm.WriteModel.AppendEvents(e)
}
}
}
func (wm *OIDCApplicationWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *project.ApplicationAddedEvent:
wm.ClientID = ""
wm.HashedSecret = ""
wm.ClientSecretString = ""
wm.RedirectUris = nil
wm.ResponseTypes = nil
wm.GrantTypes = nil
wm.ApplicationType = domain.OIDCApplicationTypeWeb
wm.AuthMethodType = domain.OIDCAuthMethodTypeBasic
wm.PostLogoutRedirectUris = nil
wm.OIDCVersion = domain.OIDCVersionV1
wm.Compliance = nil
wm.DevMode = false
wm.AccessTokenType = domain.OIDCTokenTypeBearer
wm.AccessTokenRoleAssertion = false
wm.IDTokenRoleAssertion = false
wm.IDTokenUserinfoAssertion = false
wm.ClockSkew = 0
wm.AdditionalOrigins = nil
wm.SkipNativeAppSuccessPage = false
wm.BackChannelLogoutURI = ""
wm.LoginVersion = domain.LoginVersionUnspecified
wm.LoginBaseURI = ""
wm.IOSTeamID = ""
wm.IOSBundleID = ""
wm.AndroidPackageName = ""
wm.AndroidSHA256CertFingerprints = nil
wm.oidc = false
wm.AppName = e.Name
wm.State = domain.AppStateActive
case *project.ApplicationChangedEvent:
wm.AppName = e.Name
case *project.ApplicationDeactivatedEvent:
if wm.State == domain.AppStateRemoved {
continue
}
wm.State = domain.AppStateInactive
case *project.ApplicationReactivatedEvent:
if wm.State == domain.AppStateRemoved {
continue
}
wm.State = domain.AppStateActive
case *project.ApplicationRemovedEvent:
wm.State = domain.AppStateRemoved
case *project.OIDCConfigAddedEvent:
wm.appendAddOIDCEvent(e)
case *project.OIDCConfigChangedEvent:
wm.appendChangeOIDCEvent(e)
case *project.OIDCConfigSecretChangedEvent:
wm.HashedSecret = crypto.SecretOrEncodedHash(e.ClientSecret, e.HashedSecret)
case *project.OIDCConfigSecretHashUpdatedEvent:
wm.HashedSecret = e.HashedSecret
case *project.ProjectRemovedEvent:
wm.AppName = ""
wm.ClientID = ""
wm.HashedSecret = ""
wm.ClientSecretString = ""
wm.RedirectUris = nil
wm.ResponseTypes = nil
wm.GrantTypes = nil
wm.ApplicationType = domain.OIDCApplicationTypeWeb
wm.AuthMethodType = domain.OIDCAuthMethodTypeBasic
wm.PostLogoutRedirectUris = nil
wm.OIDCVersion = domain.OIDCVersionV1
wm.Compliance = nil
wm.DevMode = false
wm.AccessTokenType = domain.OIDCTokenTypeBearer
wm.AccessTokenRoleAssertion = false
wm.IDTokenRoleAssertion = false
wm.IDTokenUserinfoAssertion = false
wm.ClockSkew = 0
wm.AdditionalOrigins = nil
wm.SkipNativeAppSuccessPage = false
wm.BackChannelLogoutURI = ""
wm.LoginVersion = domain.LoginVersionUnspecified
wm.LoginBaseURI = ""
wm.IOSTeamID = ""
wm.IOSBundleID = ""
wm.AndroidPackageName = ""
wm.AndroidSHA256CertFingerprints = nil
wm.oidc = false
wm.State = domain.AppStateRemoved
}
}
return wm.WriteModel.Reduce()
}
func (wm *OIDCApplicationWriteModel) appendAddOIDCEvent(e *project.OIDCConfigAddedEvent) {
wm.oidc = true
wm.ClientID = e.ClientID
wm.HashedSecret = crypto.SecretOrEncodedHash(e.ClientSecret, e.HashedSecret)
wm.RedirectUris = e.RedirectUris
wm.ResponseTypes = e.ResponseTypes
wm.GrantTypes = e.GrantTypes
wm.ApplicationType = e.ApplicationType
wm.AuthMethodType = e.AuthMethodType
wm.PostLogoutRedirectUris = e.PostLogoutRedirectUris
wm.OIDCVersion = e.Version
wm.DevMode = e.DevMode
wm.AccessTokenType = e.AccessTokenType
wm.AccessTokenRoleAssertion = e.AccessTokenRoleAssertion
wm.IDTokenRoleAssertion = e.IDTokenRoleAssertion
wm.IDTokenUserinfoAssertion = e.IDTokenUserinfoAssertion
wm.ClockSkew = e.ClockSkew
wm.AdditionalOrigins = e.AdditionalOrigins
wm.SkipNativeAppSuccessPage = e.SkipNativeAppSuccessPage
wm.BackChannelLogoutURI = e.BackChannelLogoutURI
wm.LoginVersion = e.LoginVersion
wm.LoginBaseURI = e.LoginBaseURI
wm.IOSTeamID = e.IOSTeamID
wm.IOSBundleID = e.IOSBundleID
wm.AndroidPackageName = e.AndroidPackageName
wm.AndroidSHA256CertFingerprints = e.AndroidSHA256CertFingerprints
}
func (wm *OIDCApplicationWriteModel) appendChangeOIDCEvent(e *project.OIDCConfigChangedEvent) {
if e.RedirectUris != nil {
wm.RedirectUris = *e.RedirectUris
}
if e.ResponseTypes != nil {
wm.ResponseTypes = *e.ResponseTypes
}
if e.GrantTypes != nil {
wm.GrantTypes = *e.GrantTypes
}
if e.ApplicationType != nil {
wm.ApplicationType = *e.ApplicationType
}
if e.AuthMethodType != nil {
wm.AuthMethodType = *e.AuthMethodType
}
if e.PostLogoutRedirectUris != nil {
wm.PostLogoutRedirectUris = *e.PostLogoutRedirectUris
}
if e.Version != nil {
wm.OIDCVersion = *e.Version
}
if e.DevMode != nil {
wm.DevMode = *e.DevMode
}
if e.AccessTokenType != nil {
wm.AccessTokenType = *e.AccessTokenType
}
if e.AccessTokenRoleAssertion != nil {
wm.AccessTokenRoleAssertion = *e.AccessTokenRoleAssertion
}
if e.IDTokenRoleAssertion != nil {
wm.IDTokenRoleAssertion = *e.IDTokenRoleAssertion
}
if e.IDTokenUserinfoAssertion != nil {
wm.IDTokenUserinfoAssertion = *e.IDTokenUserinfoAssertion
}
if e.ClockSkew != nil {
wm.ClockSkew = *e.ClockSkew
}
if e.AdditionalOrigins != nil {
wm.AdditionalOrigins = *e.AdditionalOrigins
}
if e.SkipNativeAppSuccessPage != nil {
wm.SkipNativeAppSuccessPage = *e.SkipNativeAppSuccessPage
}
if e.BackChannelLogoutURI != nil {
wm.BackChannelLogoutURI = *e.BackChannelLogoutURI
}
if e.LoginVersion != nil {
wm.LoginVersion = *e.LoginVersion
}
if e.LoginBaseURI != nil {
wm.LoginBaseURI = *e.LoginBaseURI
}
if e.IOSTeamID != nil {
wm.IOSTeamID = *e.IOSTeamID
}
if e.IOSBundleID != nil {
wm.IOSBundleID = *e.IOSBundleID
}
if e.AndroidPackageName != nil {
wm.AndroidPackageName = *e.AndroidPackageName
}
if e.AndroidSHA256CertFingerprints != nil {
wm.AndroidSHA256CertFingerprints = *e.AndroidSHA256CertFingerprints
}
}
func (wm *OIDCApplicationWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(project.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
project.ProjectAddedType,
project.ApplicationAddedType,
project.ApplicationChangedType,
project.ApplicationDeactivatedType,
project.ApplicationReactivatedType,
project.ApplicationRemovedType,
project.OIDCConfigAddedType,
project.OIDCConfigChangedType,
project.OIDCConfigSecretChangedType,
project.OIDCConfigSecretHashUpdatedType,
project.ProjectRemovedType,
).Builder()
}
func (wm *OIDCApplicationWriteModel) NewChangedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
appID string,
redirectURIS,
postLogoutRedirectURIs []string,
responseTypes []domain.OIDCResponseType,
grantTypes []domain.OIDCGrantType,
appType *domain.OIDCApplicationType,
authMethodType *domain.OIDCAuthMethodType,
oidcVersion *domain.OIDCVersion,
accessTokenType *domain.OIDCTokenType,
devMode,
accessTokenRoleAssertion,
idTokenRoleAssertion,
idTokenUserinfoAssertion *bool,
clockSkew *time.Duration,
additionalOrigins []string,
skipNativeAppSuccessPage *bool,
backChannelLogoutURI *string,
loginVersion *domain.LoginVersion,
loginBaseURI *string,
iosTeamID *string,
iosBundleID *string,
androidPackageName *string,
androidSHA256CertFingerprints []string,
) (*project.OIDCConfigChangedEvent, bool, error) {
changes := make([]project.OIDCConfigChanges, 0)
var err error
if redirectURIS != nil && !slices.Equal(wm.RedirectUris, redirectURIS) {
changes = append(changes, project.ChangeRedirectURIs(redirectURIS))
}
if responseTypes != nil && !slices.Equal(wm.ResponseTypes, responseTypes) {
changes = append(changes, project.ChangeResponseTypes(responseTypes))
}
if grantTypes != nil && !slices.Equal(wm.GrantTypes, grantTypes) {
changes = append(changes, project.ChangeGrantTypes(grantTypes))
}
if appType != nil && wm.ApplicationType != *appType {
changes = append(changes, project.ChangeApplicationType(*appType))
}
if authMethodType != nil && wm.AuthMethodType != *authMethodType {
changes = append(changes, project.ChangeAuthMethodType(*authMethodType))
}
if postLogoutRedirectURIs != nil && !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) {
changes = append(changes, project.ChangePostLogoutRedirectURIs(postLogoutRedirectURIs))
}
if oidcVersion != nil && wm.OIDCVersion != *oidcVersion {
changes = append(changes, project.ChangeVersion(*oidcVersion))
}
if devMode != nil && wm.DevMode != *devMode {
changes = append(changes, project.ChangeDevMode(*devMode))
}
if accessTokenType != nil && wm.AccessTokenType != *accessTokenType {
changes = append(changes, project.ChangeAccessTokenType(*accessTokenType))
}
if accessTokenRoleAssertion != nil && wm.AccessTokenRoleAssertion != *accessTokenRoleAssertion {
changes = append(changes, project.ChangeAccessTokenRoleAssertion(*accessTokenRoleAssertion))
}
if idTokenRoleAssertion != nil && wm.IDTokenRoleAssertion != *idTokenRoleAssertion {
changes = append(changes, project.ChangeIDTokenRoleAssertion(*idTokenRoleAssertion))
}
if idTokenUserinfoAssertion != nil && wm.IDTokenUserinfoAssertion != *idTokenUserinfoAssertion {
changes = append(changes, project.ChangeIDTokenUserinfoAssertion(*idTokenUserinfoAssertion))
}
if clockSkew != nil && wm.ClockSkew != *clockSkew {
changes = append(changes, project.ChangeClockSkew(*clockSkew))
}
if additionalOrigins != nil && !slices.Equal(wm.AdditionalOrigins, additionalOrigins) {
changes = append(changes, project.ChangeAdditionalOrigins(additionalOrigins))
}
if skipNativeAppSuccessPage != nil && wm.SkipNativeAppSuccessPage != *skipNativeAppSuccessPage {
changes = append(changes, project.ChangeSkipNativeAppSuccessPage(*skipNativeAppSuccessPage))
}
if backChannelLogoutURI != nil && wm.BackChannelLogoutURI != *backChannelLogoutURI {
changes = append(changes, project.ChangeBackChannelLogoutURI(*backChannelLogoutURI))
}
if loginVersion != nil && wm.LoginVersion != *loginVersion {
changes = append(changes, project.ChangeOIDCLoginVersion(*loginVersion))
}
if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI {
changes = append(changes, project.ChangeOIDCLoginBaseURI(*loginBaseURI))
}
if iosTeamID != nil && wm.IOSTeamID != *iosTeamID {
changes = append(changes, project.ChangeIOSTeamID(*iosTeamID))
}
if iosBundleID != nil && wm.IOSBundleID != *iosBundleID {
changes = append(changes, project.ChangeIOSBundleID(*iosBundleID))
}
if androidPackageName != nil && wm.AndroidPackageName != *androidPackageName {
changes = append(changes, project.ChangeAndroidPackageName(*androidPackageName))
}
if androidSHA256CertFingerprints != nil && !slices.Equal(wm.AndroidSHA256CertFingerprints, androidSHA256CertFingerprints) {
changes = append(changes, project.ChangeAndroidSHA256CertFingerprints(androidSHA256CertFingerprints))
}
if len(changes) == 0 {
return nil, false, nil
}
changeEvent, err := project.NewOIDCConfigChangedEvent(ctx, aggregate, appID, changes)
if err != nil {
return nil, false, err
}
return changeEvent, true, nil
}
func (wm *OIDCApplicationWriteModel) IsOIDC() bool {
return wm.oidc
}