Files
zitadel/internal/api/well_known/integration_test/well_known_test.go
T
3c3bd2bc57 fix(api): correct caching and relations of well-known app link files (#12634)
# 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=<n>`. 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=<n>, 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=<n>, 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 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-24 15:25:39 +02:00

188 lines
6.0 KiB
Go

//go:build integration
package well_known_test
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/api/well_known"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/application/v2"
)
var (
CTX context.Context
IAMOwnerCtx context.Context
Instance *integration.Instance
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
Instance = integration.NewInstance(ctx)
CTX = ctx
IAMOwnerCtx = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
return m.Run()
}())
}
func TestWellKnownAppLinks(t *testing.T) {
issuer := Instance.OIDCIssuer()
t.Run("empty when no apps configured", func(t *testing.T) {
assertEventuallyJSON(t, issuer+well_known.AppleAppSiteAssociationPath, map[string]any{
"webcredentials": map[string]any{
"apps": []any{},
},
})
assertEventuallyJSON(t, issuer+well_known.AssetLinksPath, []any{})
})
project := Instance.CreateProject(IAMOwnerCtx, t, Instance.DefaultOrg.GetId(), integration.ProjectName(), false, false)
ios1 := &application.IOSAppLinkConfig{TeamId: "TEAMAAAAAA", BundleId: "com.example.one"}
android1 := &application.AndroidAppLinkConfig{
PackageName: "com.example.one",
Sha256CertFingerprints: []string{"11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11"},
}
createOIDCAppWithLinks(t, project.GetId(), ios1, android1)
ios2 := &application.IOSAppLinkConfig{TeamId: "TEAMBBBBBB", BundleId: "com.example.two"}
createOIDCAppWithLinks(t, project.GetId(), ios2, nil)
android2 := &application.AndroidAppLinkConfig{
PackageName: "com.example.two",
Sha256CertFingerprints: []string{"22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22:22"},
}
createOIDCAppWithLinks(t, project.GetId(), nil, android2)
// duplicate iOS app id should be deduped in AASA
createOIDCAppWithLinks(t, project.GetId(), ios1, nil)
assertEventuallyJSONUnorderedApps(t, issuer,
[]string{
"TEAMAAAAAA.com.example.one",
"TEAMBBBBBB.com.example.two",
},
[]any{
map[string]any{
"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",
"sha256_cert_fingerprints": []any{android1.Sha256CertFingerprints[0]},
},
},
map[string]any{
"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",
"sha256_cert_fingerprints": []any{android2.Sha256CertFingerprints[0]},
},
},
},
)
}
func createOIDCAppWithLinks(t *testing.T, projectID string, ios *application.IOSAppLinkConfig, android *application.AndroidAppLinkConfig) {
t.Helper()
_, err := Instance.Client.ApplicationV2.CreateApplication(IAMOwnerCtx, &application.CreateApplicationRequest{
ProjectId: projectID,
Name: integration.ApplicationName(),
ApplicationType: &application.CreateApplicationRequest_OidcConfiguration{
OidcConfiguration: &application.CreateOIDCApplicationRequest{
RedirectUris: []string{"http://example.com"},
ResponseTypes: []application.OIDCResponseType{application.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE},
GrantTypes: []application.OIDCGrantType{application.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE},
ApplicationType: application.OIDCApplicationType_OIDC_APP_TYPE_NATIVE,
AuthMethodType: application.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE,
Version: application.OIDCVersion_OIDC_VERSION_1_0,
AccessTokenType: application.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER,
Ios: ios,
Android: android,
},
},
})
require.NoError(t, err)
}
func assertEventuallyJSON(t *testing.T, url string, want any) {
t.Helper()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(ct *assert.CollectT) {
resp, err := http.Get(url)
if !assert.NoError(ct, err) {
return
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
if !assert.Equal(ct, http.StatusOK, resp.StatusCode) {
return
}
assert.Contains(ct, resp.Header.Get("Content-Type"), "application/json")
assert.Equal(ct, "max-age=300, must-revalidate", resp.Header.Get("Cache-Control"))
body, err := io.ReadAll(resp.Body)
if !assert.NoError(ct, err) {
return
}
var got any
if !assert.NoError(ct, json.Unmarshal(body, &got)) {
return
}
assert.Equal(ct, want, got)
}, retryDuration, tick)
}
func assertEventuallyJSONUnorderedApps(t *testing.T, url string, wantApps []string, wantAssetLinks []any) {
t.Helper()
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
require.EventuallyWithT(t, func(ct *assert.CollectT) {
aasaResp, err := http.Get(url + well_known.AppleAppSiteAssociationPath)
if !assert.NoError(ct, err) {
return
}
defer aasaResp.Body.Close()
if !assert.Equal(ct, http.StatusOK, aasaResp.StatusCode) {
return
}
var aasa struct {
WebCredentials struct {
Apps []string `json:"apps"`
} `json:"webcredentials"`
}
if !assert.NoError(ct, json.NewDecoder(aasaResp.Body).Decode(&aasa)) {
return
}
assert.ElementsMatch(ct, wantApps, aasa.WebCredentials.Apps)
assetResp, err := http.Get(url + well_known.AssetLinksPath)
if !assert.NoError(ct, err) {
return
}
defer assetResp.Body.Close()
if !assert.Equal(ct, http.StatusOK, assetResp.StatusCode) {
return
}
var got []any
if !assert.NoError(ct, json.NewDecoder(assetResp.Body).Decode(&got)) {
return
}
assert.ElementsMatch(ct, wantAssetLinks, got)
}, retryDuration, tick)
}