diff --git a/apps/docs/content/guides/integrate/login/oidc/native-app-links.mdx b/apps/docs/content/guides/integrate/login/oidc/native-app-links.mdx index b2a0645c9..c31b3f004 100644 --- a/apps/docs/content/guides/integrate/login/oidc/native-app-links.mdx +++ b/apps/docs/content/guides/integrate/login/oidc/native-app-links.mdx @@ -51,7 +51,7 @@ ZITADEL aggregates association data from **active** OIDC apps on the instance an | Path | Purpose | |------|---------| | `${CUSTOM_DOMAIN}/.well-known/apple-app-site-association` | iOS Associated Domains / `webcredentials` | -| `${CUSTOM_DOMAIN}/.well-known/assetlinks.json` | Android Digital Asset Links for `delegate_permission/common.get_login_creds` | +| `${CUSTOM_DOMAIN}/.well-known/assetlinks.json` | Android Digital Asset Links for `delegate_permission/common.handle_all_urls` and `delegate_permission/common.get_login_creds` | If no apps are configured, both endpoints still return HTTP `200` with empty collections. @@ -75,7 +75,10 @@ Duplicate iOS App IDs across apps are deduplicated. ```json [ { - "relation": ["delegate_permission/common.get_login_creds"], + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], "target": { "namespace": "android_app", "package_name": "com.example.one", @@ -89,28 +92,20 @@ Duplicate iOS App IDs across apps are deduplicated. Fingerprints are normalized to colon-separated uppercase hex when served. +Both relations from [Google's Credential Manager prerequisites](https://developer.android.com/identity/credential-manager/prerequisites) are included, as some devices reject passkey creation when `handle_all_urls` is missing. +The additional relation only takes effect if your app declares matching intent filters. + ## Caching -Successful responses include a `Cache-Control` header so intermediate caches may reuse the documents for a short time. - -By default (and in ZITADEL Cloud) caching is allowed for 5 minutes: +The contents are specific to your instance domain, so HTTP caching is disabled and the files are always served directly by ZITADEL: ``` -Cache-Control: public, max-age=300 +Cache-Control: no-store ``` -Self-hosters can change this with the `ZITADEL_WELLKNOWN_APPLINKSCACHECONTROLMAXAGE` environment variable or in the settings YAML: - -```yaml -WellKnown: - AppLinksCacheControlMaxAge: 5m -``` - -Setting the value to `0` results in `Cache-Control: no-store`. - -Platform verifiers may cache association files on their side as well. -After you change app link settings, allow time for HTTP caches and OS verification to refresh before expecting passkeys to work with the new values. +Platform verifiers cache association files on their side. +After you change app link settings, allow time for OS verification to refresh before expecting passkeys to work with the new values. ## Verify @@ -124,7 +119,7 @@ Confirm: - HTTP status `200` - `Content-Type: application/json` -- Expected `Cache-Control` header +- `Cache-Control: no-store` header - JSON contents match your configured apps ## Related diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index c37b3a940..294e63d2a 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -701,11 +701,6 @@ OIDC: # Lifetime of the token used to notify clients through OIDC back-channel logout. TokenLifetime: 15m # ZITADEL_OIDC_BACKCHANNELLOGOUT_TOKENLIFETIME -WellKnown: - # HTTP Cache-Control max-age for /.well-known/apple-app-site-association and - # /.well-known/assetlinks.json. 0 sets Cache-Control: no-store. - AppLinksCacheControlMaxAge: 5m # ZITADEL_WELLKNOWN_APPLINKSCACHECONTROLMAXAGE - SAML: DefaultLoginURLV2: "/ui/v2/login/login?samlRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: diff --git a/cmd/start/config.go b/cmd/start/config.go index 2591dfb24..1ddc9c71b 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -24,7 +24,6 @@ import ( scim_config "github.com/zitadel/zitadel/internal/api/scim/config" "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/login" - "github.com/zitadel/zitadel/internal/api/well_known" auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing" "github.com/zitadel/zitadel/internal/cache/connector" "github.com/zitadel/zitadel/internal/command" @@ -73,7 +72,6 @@ type Config struct { SCIM scim_config.Config Login login.Config Console console.Config - WellKnown well_known.Config AssetStorage static_config.AssetStorageConfig InternalAuthZ authz.Config SystemAuthZ authz.Config diff --git a/cmd/start/start.go b/cmd/start/start.go index 73c4838df..8d04e57bd 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -659,7 +659,7 @@ func startAPIs( apis.RegisterHandlerOnPrefix(robots_txt.HandlerPrefix, robotsTxtHandler) // native app link well-known files (instance-scoped) - apis.RegisterHandlerPrefixes(instanceInterceptor.Handler(well_known.NewHandler(queries, config.WellKnown)), well_known.HandlerPrefixes...) + apis.RegisterHandlerPrefixes(instanceInterceptor.Handler(well_known.NewHandler(queries)), well_known.HandlerPrefixes...) // TODO: Record openapi access logs? openAPIHandler, err := openapi.Start() diff --git a/internal/api/well_known/integration_test/well_known_test.go b/internal/api/well_known/integration_test/well_known_test.go index 1ea17d4f7..f257e8cdd 100644 --- a/internal/api/well_known/integration_test/well_known_test.go +++ b/internal/api/well_known/integration_test/well_known_test.go @@ -134,7 +134,7 @@ func assertEventuallyJSON(t *testing.T, url string, want any) { return } assert.Contains(ct, resp.Header.Get("Content-Type"), "application/json") - assert.Equal(ct, "max-age=300, must-revalidate", resp.Header.Get("Cache-Control")) + assert.Equal(ct, "no-store", resp.Header.Get("Cache-Control")) body, err := io.ReadAll(resp.Body) if !assert.NoError(ct, err) { diff --git a/internal/api/well_known/well_known.go b/internal/api/well_known/well_known.go index 0e1ffdfe6..92ac46875 100644 --- a/internal/api/well_known/well_known.go +++ b/internal/api/well_known/well_known.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" "strings" - "time" "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" http_util "github.com/zitadel/zitadel/internal/api/http" @@ -26,28 +25,16 @@ var HandlerPrefixes = []string{ AssetLinksPath, } -// Config holds runtime options for the well-known handlers. -type Config struct { - // AppLinksCacheControlMaxAge sets the Cache-Control max-age for - // apple-app-site-association and assetlinks.json responses. - // 0 sets Cache-Control: no-store. - AppLinksCacheControlMaxAge time.Duration -} - type appLinkQuerier interface { SearchOIDCAppLinkConfigs(ctx context.Context) ([]*query.OIDCAppLinkConfig, error) } type Handler struct { - queries appLinkQuerier - cacheControlMaxAge time.Duration + queries appLinkQuerier } -func NewHandler(queries appLinkQuerier, config Config) *Handler { - return &Handler{ - queries: queries, - cacheControlMaxAge: config.AppLinksCacheControlMaxAge, - } +func NewHandler(queries appLinkQuerier) *Handler { + return &Handler{queries: queries} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -182,7 +169,17 @@ func normalizeSHA256Fingerprint(ctx context.Context, fp string) string { func (h *Handler) writeJSON(w http.ResponseWriter, r *http.Request, v any) { w.Header().Set("Content-Type", "application/json") - h.setCacheControl(w) + // The bodies are instance specific: the same path serves different content + // per requested host, and the request field selecting the instance can also + // be a (configurable) proxy header rather than Host. HTTP caching must + // therefore be disabled entirely: a shared cache whose key does not cover + // the effective selector would serve one instance's file on another + // instance's domain. Caching is also of no use here, as the only relevant + // consumers (the Apple and Google association verifiers) fetch rarely and + // cache on their side regardless of these headers. Should the query load + // ever become a concern, add a server-side per-instance cache instead of + // HTTP caching. + w.Header().Set(http_util.CacheControl, "no-store") w.WriteHeader(http.StatusOK) if r.Method == http.MethodHead { return @@ -191,23 +188,3 @@ func (h *Handler) writeJSON(w http.ResponseWriter, r *http.Request, v any) { logging.Error(r.Context(), "unable to encode well-known response", "err", err) } } - -// setCacheControl sets the Cache-Control header for the well-known app link files. -// -// The header intentionally omits the "public" directive and mirrors the JWKS -// endpoint (max-age + must-revalidate). In ZITADEL Cloud, the CDN is configured -// to cache only responses that explicitly opt in via "public" or "s-maxage". -// With "public", a fleet of independent edge caches could each pin -// a momentarily inconsistent (e.g. empty) copy and serve diverging content for -// the whole max-age, breaking native passkey verification. Without it, clients -// (the Apple and Google verifiers, browsers) still cache for max-age while the -// origin stays the single source of truth. -func (h *Handler) setCacheControl(w http.ResponseWriter) { - if h.cacheControlMaxAge <= 0 { - w.Header().Set(http_util.CacheControl, "no-store") - return - } - w.Header().Set(http_util.CacheControl, - fmt.Sprintf("max-age=%d, must-revalidate", int(h.cacheControlMaxAge/time.Second)), - ) -} diff --git a/internal/api/well_known/well_known_test.go b/internal/api/well_known/well_known_test.go index 8fbb55114..1ec609dc7 100644 --- a/internal/api/well_known/well_known_test.go +++ b/internal/api/well_known/well_known_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -183,45 +182,18 @@ func (s stubAppLinkQuerier) SearchOIDCAppLinkConfigs(_ context.Context) ([]*quer func TestHandlerCacheControl(t *testing.T) { t.Parallel() - tt := []struct { - name string - maxAge time.Duration - wantHeader string - }{ - { - name: "default max-age", - maxAge: 5 * time.Minute, - wantHeader: "max-age=300, must-revalidate", - }, - { - name: "no-store when zero", - maxAge: 0, - wantHeader: "no-store", - }, - { - name: "no-store when negative", - maxAge: -time.Minute, - wantHeader: "no-store", - }, - } - - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { + h := NewHandler(stubAppLinkQuerier{}) + for _, path := range []string{AppleAppSiteAssociationPath, AssetLinksPath} { + t.Run(path, func(t *testing.T) { t.Parallel() - h := NewHandler(stubAppLinkQuerier{}, Config{AppLinksCacheControlMaxAge: tc.maxAge}) - - for _, path := range []string{AppleAppSiteAssociationPath, AssetLinksPath} { - req := httptest.NewRequest(http.MethodGet, path, nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - require.Equal(t, http.StatusOK, rec.Code) - cacheControl := rec.Header().Get(http_util.CacheControl) - assert.Equal(t, tc.wantHeader, cacheControl, path) - assert.NotContains(t, cacheControl, "stale-while-revalidate") - // "public" must never be set: it opts the response into shared caches - // (e.g. a CDN), whose independent edges can pin diverging copies. - assert.NotContains(t, cacheControl, "public", path) - } + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + // The bodies are instance specific, so HTTP caching must be disabled: + // a shared cache whose key does not cover the instance selector would + // serve one instance's file on another instance's domain. + assert.Equal(t, "no-store", rec.Header().Get(http_util.CacheControl)) }) } } @@ -229,7 +201,7 @@ func TestHandlerCacheControl(t *testing.T) { func TestHandlerHeadOmitsBody(t *testing.T) { t.Parallel() - h := NewHandler(stubAppLinkQuerier{}, Config{AppLinksCacheControlMaxAge: 5 * time.Minute}) + h := NewHandler(stubAppLinkQuerier{}) for _, path := range []string{AppleAppSiteAssociationPath, AssetLinksPath} { t.Run(path, func(t *testing.T) { t.Parallel() @@ -238,7 +210,7 @@ func TestHandlerHeadOmitsBody(t *testing.T) { h.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) - assert.Equal(t, "max-age=300, must-revalidate", rec.Header().Get(http_util.CacheControl)) + assert.Equal(t, "no-store", rec.Header().Get(http_util.CacheControl)) assert.Empty(t, rec.Body.Bytes()) }) } diff --git a/proto/zitadel/app.proto b/proto/zitadel/app.proto index c4486c17a..5c9fee8be 100644 --- a/proto/zitadel/app.proto +++ b/proto/zitadel/app.proto @@ -181,12 +181,12 @@ message OIDCConfig { ]; IOSAppLinkConfig ios = 23 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). Platform verifiers may cache the file; changes can take time to take effect."; } ]; AndroidAppLinkConfig android = 24 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.handle_all_urls and delegate_permission/common.get_login_creds. Platform verifiers may cache the file; changes can take time to take effect."; } ]; } diff --git a/proto/zitadel/application/v2/application_service.proto b/proto/zitadel/application/v2/application_service.proto index a84d62345..256136f9f 100644 --- a/proto/zitadel/application/v2/application_service.proto +++ b/proto/zitadel/application/v2/application_service.proto @@ -345,14 +345,12 @@ message CreateOIDCApplicationRequest { // IOS is iOS Associated Domains / passkey trust config. // Served in /.well-known/apple-app-site-association as webcredentials.apps entry // "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). - // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; - // changes can take time to take effect. + // Platform verifiers may cache the file; changes can take time to take effect. IOSAppLinkConfig ios = 18; // Android is Android Digital Asset Links / passkey trust config. - // Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. - // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; - // changes can take time to take effect. + // Served in /.well-known/assetlinks.json for delegate_permission/common.handle_all_urls and delegate_permission/common.get_login_creds. + // Platform verifiers may cache the file; changes can take time to take effect. AndroidAppLinkConfig android = 19; } @@ -595,15 +593,13 @@ message UpdateOIDCApplicationConfigurationRequest { // IOS is iOS Associated Domains / passkey trust config. // Served in /.well-known/apple-app-site-association as webcredentials.apps entry // "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). - // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; - // changes can take time to take effect. + // Platform verifiers may cache the file; changes can take time to take effect. // If not set, the iOS config will not be changed. optional IOSAppLinkConfig ios = 18; // Android is Android Digital Asset Links / passkey trust config. - // Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. - // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; - // changes can take time to take effect. + // Served in /.well-known/assetlinks.json for delegate_permission/common.handle_all_urls and delegate_permission/common.get_login_creds. + // Platform verifiers may cache the file; changes can take time to take effect. // If not set, the Android config will not be changed. optional AndroidAppLinkConfig android = 19; } diff --git a/proto/zitadel/application/v2/oidc.proto b/proto/zitadel/application/v2/oidc.proto index 95f7b4355..db5b22316 100644 --- a/proto/zitadel/application/v2/oidc.proto +++ b/proto/zitadel/application/v2/oidc.proto @@ -162,22 +162,19 @@ message OIDCConfiguration { // IOS is iOS Associated Domains / passkey trust config. // Served in /.well-known/apple-app-site-association as webcredentials.apps entry // "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). - // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; - // changes can take time to take effect. + // Platform verifiers may cache the file; changes can take time to take effect. IOSAppLinkConfig ios = 22; // Android is Android Digital Asset Links / passkey trust config. - // Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. - // That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; - // changes can take time to take effect. + // Served in /.well-known/assetlinks.json for delegate_permission/common.handle_all_urls and delegate_permission/common.get_login_creds. + // Platform verifiers may cache the file; changes can take time to take effect. AndroidAppLinkConfig android = 23; } // IOSAppLinkConfig is iOS Associated Domains / passkey trust config. // Served in /.well-known/apple-app-site-association as webcredentials.apps entry // "{team_id}.{bundle_id}" (Apple's full App ID = Team ID prefix + Bundle ID). -// That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; -// changes can take time to take effect. +// Platform verifiers may cache the file; changes can take time to take effect. message IOSAppLinkConfig { // TeamID is the Apple Developer Team ID (exactly 10 alphanumeric characters), e.g. ABCDE12345. // From Apple Developer account membership — not the Bundle ID. @@ -191,9 +188,8 @@ message IOSAppLinkConfig { } // AndroidAppLinkConfig is Android Digital Asset Links / passkey trust config. -// Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. -// That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; -// changes can take time to take effect. +// Served in /.well-known/assetlinks.json for delegate_permission/common.handle_all_urls and delegate_permission/common.get_login_creds. +// Platform verifiers may cache the file; changes can take time to take effect. message AndroidAppLinkConfig { // PackageName is the Android applicationId / package name from the app manifest. // Empty clears the value on update. diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index a416a3912..caf4112bf 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -10384,12 +10384,12 @@ message AddOIDCAppRequest { ]; zitadel.app.v1.IOSAppLinkConfig ios = 20 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). Platform verifiers may cache the file; changes can take time to take effect."; } ]; zitadel.app.v1.AndroidAppLinkConfig android = 21 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.handle_all_urls and delegate_permission/common.get_login_creds. Platform verifiers may cache the file; changes can take time to take effect."; } ]; } @@ -10585,12 +10585,12 @@ message UpdateOIDCAppConfigRequest { ]; zitadel.app.v1.IOSAppLinkConfig ios = 19 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + description: "iOS Associated Domains / passkey trust config. Served in /.well-known/apple-app-site-association as webcredentials.apps entry \"{team_id}.{bundle_id}\" (Apple's full App ID). Platform verifiers may cache the file; changes can take time to take effect."; } ]; zitadel.app.v1.AndroidAppLinkConfig android = 20 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.get_login_creds. That response may be HTTP-cached (Cache-Control), and platform verifiers may cache longer; changes can take time to take effect."; + description: "Android Digital Asset Links / passkey trust config. Served in /.well-known/assetlinks.json for delegate_permission/common.handle_all_urls and delegate_permission/common.get_login_creds. Platform verifiers may cache the file; changes can take time to take effect."; } ]; }