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