From 3c3bd2bc571eb2236de1a0d32fd06874c38e8eb7 Mon Sep 17 00:00:00 2001 From: Livio Spring <9405495+livio-a@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:25:39 +0200 Subject: [PATCH] fix(api): correct caching and relations of well-known app link files (#12634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Two issues with the native app link well-known files reported after the v4.17.0 rollout: **1. `assetlinks.json` intermittently returned an empty `[]`** on ZITADEL Cloud without any configuration change, across all of a customer's environments, breaking native passkey verification. The configuration itself was stored correctly. The responses were served with `Cache-Control: public, max-age=`. The `public` directive opts the response into shared caches: the CDN in front of ZITADEL Cloud is configured to cache only responses that explicitly set `public` or `s-maxage`. That cache is a fleet of independent edge nodes, each holding its own copy. A single momentarily inconsistent origin response (e.g. an empty file) gets pinned on the edge that captured it for the whole `max-age` and served to every client routed there, while other edges serve the correct file — producing the observed flip-flop. This is confirmed by the JWKS endpoint, which serves `max-age=, must-revalidate` **without** `public`, has never been cached by the CDN, and has never shown this inconsistency. **2. Passkey creation failed on Google Pixel devices** while working on iOS and other Android devices. Google's [Credential Manager prerequisites](https://developer.android.com/identity/credential-manager/prerequisites) require the Digital Asset Links statement for passkeys to declare **both** `delegate_permission/common.handle_all_urls` and `delegate_permission/common.get_login_creds`; ZITADEL only served `get_login_creds`, and Pixel devices enforce the documented contract strictly. # How the Problems Are Solved - Drop `public` and mirror the JWKS endpoint: `Cache-Control: max-age=, must-revalidate`. Clients (the Apple and Google verifiers, browsers) still cache the files for `max-age`; the origin stays the single source of truth. The load profile matches JWKS, which already serves every request from origin at far higher volume, so this does not introduce a thundering-herd risk. A test guard asserts `public` is never emitted. - Emit both required relations in `/.well-known/assetlinks.json`, matching Google's documented example. The additional relation only permits the app to handle links for the domain; it has no effect unless the app itself declares matching intent filters, and the entry is configured by the app owner. # Additional Changes None. `AppLinksCacheControlMaxAge` and its `0 → no-store` behaviour are unchanged. # Additional Context - Introduced with the native app links feature (#12580 / #12497). - Immediate relief for the caching issue was applied by invalidating the CDN cache for both paths; this change prevents recurrence. - To be cherry-picked to `v4.x` from `main` once merged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../integration_test/well_known_test.go | 6 +++--- internal/api/well_known/well_known.go | 21 +++++++++++++++++-- internal/api/well_known/well_known_test.go | 16 ++++++++------ 3 files changed, 32 insertions(+), 11 deletions(-) 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 d5e900881..1ea17d4f7 100644 --- a/internal/api/well_known/integration_test/well_known_test.go +++ b/internal/api/well_known/integration_test/well_known_test.go @@ -77,7 +77,7 @@ func TestWellKnownAppLinks(t *testing.T) { }, []any{ map[string]any{ - "relation": []any{"delegate_permission/common.get_login_creds"}, + "relation": []any{"delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"}, "target": map[string]any{ "namespace": "android_app", "package_name": "com.example.one", @@ -85,7 +85,7 @@ func TestWellKnownAppLinks(t *testing.T) { }, }, map[string]any{ - "relation": []any{"delegate_permission/common.get_login_creds"}, + "relation": []any{"delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"}, "target": map[string]any{ "namespace": "android_app", "package_name": "com.example.two", @@ -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, "public, max-age=300", resp.Header.Get("Cache-Control")) + assert.Equal(ct, "max-age=300, must-revalidate", 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 15007d80c..0e1ffdfe6 100644 --- a/internal/api/well_known/well_known.go +++ b/internal/api/well_known/well_known.go @@ -134,7 +134,14 @@ func buildAssetLinks(ctx context.Context, configs []*query.OIDCAppLinkConfig) [] continue } links = append(links, assetLink{ - Relation: []string{"delegate_permission/common.get_login_creds"}, + // Google's Credential Manager prerequisites require both relations for + // passkeys; some devices (e.g. Pixel) reject passkey creation when + // handle_all_urls is missing. + // https://developer.android.com/identity/credential-manager/prerequisites + Relation: []string{ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds", + }, Target: assetLinkTarget{ Namespace: "android_app", PackageName: cfg.AndroidPackageName, @@ -185,12 +192,22 @@ func (h *Handler) writeJSON(w http.ResponseWriter, r *http.Request, v any) { } } +// 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("public, max-age=%d", int(h.cacheControlMaxAge/time.Second)), + 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 868b11645..8fbb55114 100644 --- a/internal/api/well_known/well_known_test.go +++ b/internal/api/well_known/well_known_test.go @@ -96,7 +96,7 @@ func TestBuildAssetLinks(t *testing.T) { }, want: []assetLink{ { - Relation: []string{"delegate_permission/common.get_login_creds"}, + Relation: []string{"delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"}, Target: assetLinkTarget{ Namespace: "android_app", PackageName: "com.one", @@ -106,7 +106,7 @@ func TestBuildAssetLinks(t *testing.T) { }, }, { - Relation: []string{"delegate_permission/common.get_login_creds"}, + Relation: []string{"delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"}, Target: assetLinkTarget{ Namespace: "android_app", PackageName: "com.two", @@ -191,7 +191,7 @@ func TestHandlerCacheControl(t *testing.T) { { name: "default max-age", maxAge: 5 * time.Minute, - wantHeader: "public, max-age=300", + wantHeader: "max-age=300, must-revalidate", }, { name: "no-store when zero", @@ -215,8 +215,12 @@ func TestHandlerCacheControl(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, tc.wantHeader, rec.Header().Get(http_util.CacheControl), path) - assert.NotContains(t, rec.Header().Get(http_util.CacheControl), "stale-while-revalidate") + 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) } }) } @@ -234,7 +238,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, "public, max-age=300", rec.Header().Get(http_util.CacheControl)) + assert.Equal(t, "max-age=300, must-revalidate", rec.Header().Get(http_util.CacheControl)) assert.Empty(t, rec.Body.Bytes()) }) }