mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-11 08:35:13 +00:00
# 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]>
188 lines
6.0 KiB
Go
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)
|
|
}
|