diff --git a/api/application.yaml b/api/application.yaml
index 433c2204a3..8e2f01cd21 100644
--- a/api/application.yaml
+++ b/api/application.yaml
@@ -1372,10 +1372,15 @@ components:
type: object
description: >
Platform attestation configuration used to verify the binary identity of a mobile client
- when it initiates a flow directly over HTTP.
+ when it initiates a flow directly over HTTP. Configure exactly one platform.
+ oneOf:
+ - required: [android]
+ - required: [apple]
properties:
android:
$ref: '#/components/schemas/AndroidAttestation'
+ apple:
+ $ref: '#/components/schemas/AppleAttestation'
AndroidAttestation:
type: object
@@ -1400,6 +1405,22 @@ components:
Google Cloud service account credentials (JSON) used to call the Play Integrity API.
Write-only: never returned in responses. Omit on update to preserve the stored value.
+ AppleAttestation:
+ type: object
+ description: Apple App Attest attestation configuration for iOS clients.
+ required: [teamId, bundleId]
+ properties:
+ teamId:
+ type: string
+ minLength: 1
+ description: Apple Developer Team ID.
+ example: "ABCDE12345"
+ bundleId:
+ type: string
+ minLength: 1
+ description: iOS application bundle identifier that must match the attested app.
+ example: "com.example.myapp"
+
InboundAuthConfig:
type: object
properties:
diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go
index 02d076ce58..1341c98527 100644
--- a/backend/cmd/server/servicemanager.go
+++ b/backend/cmd/server/servicemanager.go
@@ -471,7 +471,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa
serverConfigService,
)
- attestationProvider := attestation.Initialize(runtimeCryptoSvc)
+ attestationProvider := initAttestationProvider(ctx, logger, runtimeCryptoSvc)
flowExecService, err := flowexec.Initialize(mux, flowMgtService, actorProvider,
execRegistry, interceptorRegistry, observabilitySvc, runtimeCryptoSvc, attestationProvider,
graphBuilder, runtimeStoreProvider, transactioner, flowConfig)
@@ -501,6 +501,17 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa
return jwtService, runtimeCryptoSvc, importService
}
+// initAttestationProvider initializes the platform attestation provider, terminating server startup
+// on failure rather than running with a non-functional verifier.
+func initAttestationProvider(ctx context.Context, logger *log.Logger,
+ cryptoSvc kmprovider.RuntimeCryptoProvider) providers.AttestationProvider {
+ attestationProvider, err := attestation.Initialize(cryptoSvc)
+ if err != nil {
+ logger.Fatal(ctx, "Failed to initialize attestation provider", log.Error(err))
+ }
+ return attestationProvider
+}
+
// dependencyConsumers groups the services that check the dependency registry before deleting their
// own resources.
type dependencyConsumers struct {
diff --git a/backend/go.mod b/backend/go.mod
index dda02e0701..9666eb186f 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -6,6 +6,7 @@ require (
cloud.google.com/go/auth v0.22.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/cloudflare/circl v1.6.4
+ github.com/fxamacker/cbor/v2 v2.9.2
github.com/go-webauthn/webauthn v0.17.4
github.com/google/jsonschema-go v0.4.3
github.com/lib/pq v1.10.9
@@ -35,7 +36,6 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
diff --git a/backend/internal/application/error_constants.go b/backend/internal/application/error_constants.go
index d37062a293..f831b58ceb 100644
--- a/backend/internal/application/error_constants.go
+++ b/backend/internal/application/error_constants.go
@@ -488,4 +488,19 @@ var (
"browser-based single-page applications.",
},
}
+ // ErrorAmbiguousAttestationConfig is returned when an application's attestation configuration
+ // sets more than one platform, which the flow-initiation verifier cannot unambiguously dispatch.
+ ErrorAmbiguousAttestationConfig = tidcommon.ServiceError{
+ Type: tidcommon.ClientErrorType,
+ Code: "APP-1038",
+ Error: tidcommon.I18nMessage{
+ Key: "error.applicationservice.ambiguous_attestation_config",
+ DefaultValue: "Attestation configuration must set exactly one platform",
+ },
+ ErrorDescription: tidcommon.I18nMessage{
+ Key: "error.applicationservice.ambiguous_attestation_config_description",
+ DefaultValue: "An application's attestation configuration may configure only one platform " +
+ "(android or apple) at a time",
+ },
+ }
)
diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go
index 5389a4c5fe..ffdf2beb79 100644
--- a/backend/internal/application/service.go
+++ b/backend/internal/application/service.go
@@ -1123,6 +1123,13 @@ func (as *applicationService) validateApplicationFields(
isOAuthConfig = true
}
as.validateConsentConfig(app)
+
+ // An attestation config identifies exactly one platform build of the app; the verifier dispatch
+ // cannot pick between two simultaneously.
+ if attestation := app.Attestation; attestation != nil &&
+ attestation.Android != nil && attestation.Apple != nil {
+ return &ErrorAmbiguousAttestationConfig
+ }
return nil
}
@@ -1692,7 +1699,7 @@ func (as *applicationService) resolveAttestationCredentialsForPersist(
}
}
- inboundClient.Attestation = &providers.AttestationConfig{Android: &android}
+ inboundClient.Attestation = &providers.AttestationConfig{Android: &android, Apple: inboundClient.Attestation.Apple}
return nil
}
diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go
index b2898402cc..6dcd187adb 100644
--- a/backend/internal/application/service_test.go
+++ b/backend/internal/application/service_test.go
@@ -1365,6 +1365,35 @@ func (suite *ServiceTestSuite) TestValidateApplication_InvalidURL() {
assert.Equal(suite.T(), &ErrorInvalidApplicationURL, svcErr)
}
+func (suite *ServiceTestSuite) TestValidateApplication_AmbiguousAttestationConfig() {
+ testConfig := &config.Config{}
+ config.ResetServerRuntime()
+ err := config.InitializeServerRuntime("/tmp/test", testConfig)
+ require.NoError(suite.T(), err)
+ defer config.ResetServerRuntime()
+
+ service, _ := suite.setupTestService()
+
+ app := &model.ApplicationDTO{
+ Name: "Test App",
+ OUID: testOUID,
+ InboundAuthProfile: providers.InboundAuthProfile{
+ AuthFlowID: "edc013d0-e893-4dc0-990c-3e1d203e005b",
+ Attestation: &providers.AttestationConfig{
+ Android: &providers.AndroidAttestationConfig{PackageName: "com.example.app"},
+ Apple: &providers.AppleAttestationConfig{TeamID: "TEAM123", BundleID: "com.example.app"},
+ },
+ },
+ }
+
+ result, inboundAuth, svcErr := service.ValidateApplication(context.Background(), app)
+
+ assert.Nil(suite.T(), result)
+ assert.Nil(suite.T(), inboundAuth)
+ assert.NotNil(suite.T(), svcErr)
+ assert.Equal(suite.T(), &ErrorAmbiguousAttestationConfig, svcErr)
+}
+
//nolint:dupl // Testing different URL validation scenarios
func (suite *ServiceTestSuite) TestValidateApplication_InvalidLogoURL() {
testConfig := &config.Config{}
diff --git a/backend/internal/attestation/app_attest.go b/backend/internal/attestation/app_attest.go
new file mode 100644
index 0000000000..d295063162
--- /dev/null
+++ b/backend/internal/attestation/app_attest.go
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package attestation
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/binary"
+ "fmt"
+
+ "github.com/fxamacker/cbor/v2"
+
+ "github.com/thunder-id/thunderid/internal/system/log"
+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common"
+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
+)
+
+// appAttestVerifier verifies Apple App Attest attestation objects for iOS clients, entirely
+// offline: it validates the attestation certificate chain against Apple's App Attest root and
+// matches the attested app identifier and key.
+//
+// It does not yet bind verification to a server-issued challenge — the same scope limitation as the
+// Play Integrity verifier's lack of request-freshness checking. Closing that gap is tracked as a
+// follow-up.
+//
+// Because verification never leaves the process, any problem with the token is a definitive
+// rejection; only a missing or incomplete configuration is an operational error.
+type appAttestVerifier struct {
+ rootPool *x509.CertPool
+ logger *log.Logger
+}
+
+// newAppAttestVerifier creates a verifier trusted against Apple's public App Attestation Root CA. It
+// errors if the embedded root certificate fails to parse, so the caller can fail server startup
+// instead of running with a non-functional verifier.
+func newAppAttestVerifier() (providers.AttestationProvider, error) {
+ pool := x509.NewCertPool()
+ if !pool.AppendCertsFromPEM([]byte(appleAppAttestRootPEM)) {
+ return nil, fmt.Errorf("attestation: failed to parse embedded Apple App Attestation Root CA")
+ }
+ return &appAttestVerifier{
+ rootPool: pool,
+ logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "AppAttestVerifier")),
+ }, nil
+}
+
+// Verify decodes the attestation object and checks the attested app identifier and key against the
+// registered Team ID and Bundle ID. A definitive rejection (malformed object, bad chain, identity
+// mismatch) is (false, nil); a missing or incomplete configuration is (false, ServiceError).
+func (v *appAttestVerifier) Verify(ctx context.Context, cfg *providers.AttestationConfig, token string) (
+ bool, *tidcommon.ServiceError) {
+ if cfg == nil || cfg.Apple == nil {
+ v.logger.Error(ctx, "Attestation requested without an Apple attestation configuration")
+ return false, &tidcommon.InternalServerError
+ }
+
+ // Both Team ID and Bundle ID are required to verify identity; reject an incomplete config up front.
+ apple := cfg.Apple
+ if apple.TeamID == "" || apple.BundleID == "" {
+ v.logger.Error(ctx, "Apple attestation configuration is incomplete")
+ return false, &tidcommon.InternalServerError
+ }
+
+ if err := v.verifyAttestation(apple, token); err != nil {
+ v.logger.Debug(ctx, "Attestation token rejected", log.Error(err))
+ return false, nil
+ }
+ return true, nil
+}
+
+// verifyAttestation decodes and validates the attestation object against the registered Apple config,
+// returning a non-nil error describing the first check that fails.
+func (v *appAttestVerifier) verifyAttestation(apple *providers.AppleAttestationConfig, token string) error {
+ raw, err := base64.StdEncoding.DecodeString(token)
+ if err != nil {
+ // Attestation objects may also be presented URL-safe; try that before failing.
+ raw, err = base64.URLEncoding.DecodeString(token)
+ if err != nil {
+ return fmt.Errorf("%w: %w", errInvalidPayload, err)
+ }
+ }
+
+ var obj attestationObject
+ if err := cbor.Unmarshal(raw, &obj); err != nil {
+ return fmt.Errorf("%w: %w", errInvalidPayload, err)
+ }
+ if obj.Fmt != appAttestFormat || len(obj.AttStmt.X5C) == 0 {
+ return errInvalidPayload
+ }
+
+ leaf, err := v.verifyCertificateChain(obj.AttStmt.X5C)
+ if err != nil {
+ return err
+ }
+
+ authData, err := v.parseAuthData(obj.AuthData)
+ if err != nil {
+ return err
+ }
+
+ if err := v.verifyAppIdentifier(authData.rpIDHash, apple); err != nil {
+ return err
+ }
+ if !v.isRecognizedAAGUID(authData.aaguid) {
+ return errEnvironmentUnrecognized
+ }
+ if authData.signCount != 0 {
+ return errSignCountNonZero
+ }
+ return v.verifyKeyIdentifier(leaf, authData.credentialID)
+}
+
+// verifyCertificateChain verifies the credCert (x5c[0]) chains to the trusted root, through any
+// intermediates presented in x5c[1:]. It returns the parsed leaf certificate on success.
+func (v *appAttestVerifier) verifyCertificateChain(x5c [][]byte) (*x509.Certificate, error) {
+ leaf, err := x509.ParseCertificate(x5c[0])
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", errInvalidPayload, err)
+ }
+
+ intermediates := x509.NewCertPool()
+ for _, der := range x5c[1:] {
+ cert, err := x509.ParseCertificate(der)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", errInvalidPayload, err)
+ }
+ intermediates.AddCert(cert)
+ }
+
+ // App Attest certs aren't TLS server certs, so relax the default ExtKeyUsageServerAuth requirement.
+ opts := x509.VerifyOptions{
+ Roots: v.rootPool,
+ Intermediates: intermediates,
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
+ }
+ if _, err := leaf.Verify(opts); err != nil {
+ return nil, fmt.Errorf("%w: %w", errCertificateChainInvalid, err)
+ }
+ return leaf, nil
+}
+
+// parseAuthData parses the fixed-layout authenticator data fields, with explicit bounds checks so
+// a short or malformed value returns an error instead of panicking.
+func (v *appAttestVerifier) parseAuthData(data []byte) (*parsedAuthData, error) {
+ if len(data) < authDataMinLen {
+ return nil, errInvalidPayload
+ }
+
+ rpIDHash := data[0:authDataRPIDHashLen]
+ flags := data[authDataRPIDHashLen]
+ if flags&authDataFlagAttestedCD == 0 {
+ return nil, errInvalidPayload
+ }
+
+ signCountOffset := authDataRPIDHashLen + authDataFlagsLen
+ signCount := binary.BigEndian.Uint32(data[signCountOffset : signCountOffset+authDataSignCountLen])
+
+ aaguidOffset := signCountOffset + authDataSignCountLen
+ var aaguid [16]byte
+ copy(aaguid[:], data[aaguidOffset:aaguidOffset+authDataAAGUIDLen])
+
+ credIDLenOffset := aaguidOffset + authDataAAGUIDLen
+ credIDLen := int(binary.BigEndian.Uint16(data[credIDLenOffset : credIDLenOffset+authDataCredIDLenLen]))
+
+ credIDOffset := credIDLenOffset + authDataCredIDLenLen
+ if len(data) < credIDOffset+credIDLen {
+ return nil, errInvalidPayload
+ }
+ credentialID := data[credIDOffset : credIDOffset+credIDLen]
+
+ return &parsedAuthData{
+ rpIDHash: rpIDHash,
+ signCount: signCount,
+ aaguid: aaguid,
+ credentialID: credentialID,
+ }, nil
+}
+
+// verifyAppIdentifier checks that the authenticator data's RP ID hash matches the SHA-256 hash of
+// the registered Team ID and Bundle ID, as Apple's App Attest spec defines the App ID.
+func (v *appAttestVerifier) verifyAppIdentifier(rpIDHash []byte, apple *providers.AppleAttestationConfig) error {
+ expected := sha256.Sum256([]byte(apple.TeamID + "." + apple.BundleID))
+ if string(rpIDHash) != string(expected[:]) {
+ return errAppIdentifierMismatch
+ }
+ return nil
+}
+
+// isRecognizedAAGUID reports whether aaguid identifies a genuine App Attest key, in either the
+// production or development environment.
+func (v *appAttestVerifier) isRecognizedAAGUID(aaguid [16]byte) bool {
+ return aaguid == aaguidProduction || aaguid == aaguidDevelopment
+}
+
+// verifyKeyIdentifier checks that the authenticator data's credential ID equals the SHA-256 hash of
+// the credCert's public key, encoded as an ANSI X9.63 uncompressed point (0x04 || X || Y), not the
+// DER SubjectPublicKeyInfo.
+func (v *appAttestVerifier) verifyKeyIdentifier(leaf *x509.Certificate, credentialID []byte) error {
+ pub, ok := leaf.PublicKey.(*ecdsa.PublicKey)
+ if !ok {
+ return errInvalidPayload
+ }
+ ecdhPub, err := pub.ECDH()
+ if err != nil {
+ return fmt.Errorf("%w: %w", errInvalidPayload, err)
+ }
+ expected := sha256.Sum256(ecdhPub.Bytes())
+ if string(credentialID) != string(expected[:]) {
+ return errKeyIdentifierMismatch
+ }
+ return nil
+}
diff --git a/backend/internal/attestation/app_attest_constants.go b/backend/internal/attestation/app_attest_constants.go
new file mode 100644
index 0000000000..7694782af7
--- /dev/null
+++ b/backend/internal/attestation/app_attest_constants.go
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package attestation
+
+// appleAppAttestRootPEM is Apple's public "Apple App Attestation Root CA" certificate, published at
+// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem. It is a public trust
+// anchor, not a secret.
+const appleAppAttestRootPEM = `-----BEGIN CERTIFICATE-----
+MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
+JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
+QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
+Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
+biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
+bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
+NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
+Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
+MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
+CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
+53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
+oyFraWVIyd/dganmrduC1bmTBGwD
+-----END CERTIFICATE-----
+`
+
+// appAttestFormat is the only attestation statement format Apple App Attest produces.
+const appAttestFormat = "apple-appattest"
+
+// authData field layout, per the WebAuthn authenticator data format Apple App Attest reuses.
+const (
+ authDataRPIDHashLen = 32
+ authDataFlagsLen = 1
+ authDataSignCountLen = 4
+ authDataAAGUIDLen = 16
+ authDataCredIDLenLen = 2
+ // authDataMinLen is the smallest valid authData: all fixed fields present with a zero-length
+ // credential ID.
+ authDataMinLen = authDataRPIDHashLen + authDataFlagsLen + authDataSignCountLen +
+ authDataAAGUIDLen + authDataCredIDLenLen
+ authDataFlagAttestedCD = 0x40
+)
+
+// Apple App Attest AAGUID values identifying the environment a key was attested in. Both are
+// accepted; this verifier does not restrict to a single environment.
+var (
+ aaguidProduction = [16]byte{'a', 'p', 'p', 'a', 't', 't', 'e', 's', 't', 0, 0, 0, 0, 0, 0, 0}
+ aaguidDevelopment = [16]byte{'a', 'p', 'p', 'a', 't', 't', 'e', 's', 't', 'd', 'e', 'v', 'e', 'l', 'o', 'p'}
+)
diff --git a/backend/internal/attestation/app_attest_test.go b/backend/internal/attestation/app_attest_test.go
new file mode 100644
index 0000000000..91fc9a8c9c
--- /dev/null
+++ b/backend/internal/attestation/app_attest_test.go
@@ -0,0 +1,371 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package attestation
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/base64"
+ "encoding/binary"
+ "math/big"
+ "testing"
+ "time"
+
+ "github.com/fxamacker/cbor/v2"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/thunder-id/thunderid/internal/system/log"
+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
+)
+
+const (
+ testTeamID = "ABCDE12345"
+ testBundleID = "com.example.myapp"
+)
+
+func appleConfig() *providers.AttestationConfig {
+ return &providers.AttestationConfig{
+ Apple: &providers.AppleAttestationConfig{TeamID: testTeamID, BundleID: testBundleID},
+ }
+}
+
+// testChain is a synthetic self-signed root + leaf certificate pair standing in for Apple's real
+// App Attest root, so the whole chain-verification path can be exercised offline.
+type testChain struct {
+ rootPool *x509.CertPool
+ leafDER []byte
+ leafCert *x509.Certificate
+}
+
+// authDataOpts customizes the synthetic authenticator data built by buildAuthData.
+type authDataOpts struct {
+ rpIDHash []byte
+ flags byte
+ signCount uint32
+ aaguid [16]byte
+ credentialID []byte
+ truncateTo int // if > 0, truncate the final authData to this length
+}
+
+func buildAuthData(o authDataOpts) []byte {
+ buf := make([]byte, 0, authDataMinLen+len(o.credentialID))
+ buf = append(buf, o.rpIDHash...)
+ buf = append(buf, o.flags)
+ sc := make([]byte, 4)
+ binary.BigEndian.PutUint32(sc, o.signCount)
+ buf = append(buf, sc...)
+ buf = append(buf, o.aaguid[:]...)
+ idLen := make([]byte, 2)
+ binary.BigEndian.PutUint16(idLen, uint16(len(o.credentialID))) //nolint:gosec // test data, small length
+ buf = append(buf, idLen...)
+ buf = append(buf, o.credentialID...)
+ if o.truncateTo > 0 && o.truncateTo < len(buf) {
+ buf = buf[:o.truncateTo]
+ }
+ return buf
+}
+
+func appIDHash(teamID string) []byte {
+ sum := sha256.Sum256([]byte(teamID + "." + testBundleID))
+ return sum[:]
+}
+
+type AppAttestVerifierTestSuite struct {
+ suite.Suite
+}
+
+func TestAppAttestVerifierTestSuite(t *testing.T) {
+ suite.Run(t, new(AppAttestVerifierTestSuite))
+}
+
+// newVerifier builds a verifier trusting the given root pool instead of Apple's real root, so tests
+// can verify the whole chain offline against a self-signed test root.
+func (s *AppAttestVerifierTestSuite) newVerifier(rootPool *x509.CertPool) *appAttestVerifier {
+ return &appAttestVerifier{
+ rootPool: rootPool,
+ logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "AppAttestVerifier")),
+ }
+}
+
+func (s *AppAttestVerifierTestSuite) generateTestChain() *testChain {
+ rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ s.Require().NoError(err)
+ rootTemplate := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "Test App Attest Root"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
+ BasicConstraintsValid: true,
+ IsCA: true,
+ }
+ rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
+ s.Require().NoError(err)
+ rootCert, err := x509.ParseCertificate(rootDER)
+ s.Require().NoError(err)
+
+ leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ s.Require().NoError(err)
+ leafTemplate := &x509.Certificate{
+ SerialNumber: big.NewInt(2),
+ Subject: pkix.Name{CommonName: "Test credCert"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ }
+ leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, rootCert, &leafKey.PublicKey, rootKey)
+ s.Require().NoError(err)
+ leafCert, err := x509.ParseCertificate(leafDER)
+ s.Require().NoError(err)
+
+ rootPool := x509.NewCertPool()
+ rootPool.AddCert(rootCert)
+
+ return &testChain{rootPool: rootPool, leafDER: leafDER, leafCert: leafCert}
+}
+
+// credentialIDFor computes the expected credential ID for cert's public key, matching
+// verifyKeyIdentifier's encoding.
+func (s *AppAttestVerifierTestSuite) credentialIDFor(cert *x509.Certificate) []byte {
+ pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
+ s.Require().True(ok)
+ ecdhPub, err := pub.ECDH()
+ s.Require().NoError(err)
+ sum := sha256.Sum256(ecdhPub.Bytes())
+ return sum[:]
+}
+
+// buildToken CBOR-encodes and base64-encodes an attestation object for the given chain and
+// authData, as presented in the Attestation-Token header.
+func (s *AppAttestVerifierTestSuite) buildToken(format string, x5c [][]byte, authData []byte) string {
+ obj := attestationObject{Fmt: format}
+ obj.AttStmt.X5C = x5c
+ obj.AuthData = authData
+ raw, err := cbor.Marshal(obj)
+ s.Require().NoError(err)
+ return base64.StdEncoding.EncodeToString(raw)
+}
+
+// assertRejected asserts a definitive rejection: not verified, with no operational error (mapped to
+// 401 by the flow layer).
+func (s *AppAttestVerifierTestSuite) assertRejected(
+ v providers.AttestationProvider, cfg *providers.AttestationConfig, token string) {
+ ok, svcErr := v.Verify(context.Background(), cfg, token)
+ s.False(ok)
+ s.Nil(svcErr)
+}
+
+// assertOperationalError asserts an operational failure: not verified, with a service error (mapped
+// to 500 by the flow layer).
+func (s *AppAttestVerifierTestSuite) assertOperationalError(
+ v providers.AttestationProvider, cfg *providers.AttestationConfig, token string) {
+ ok, svcErr := v.Verify(context.Background(), cfg, token)
+ s.False(ok)
+ s.NotNil(svcErr)
+}
+
+func (s *AppAttestVerifierTestSuite) TestVerify_Success() {
+ chain := s.generateTestChain()
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID),
+ flags: authDataFlagAttestedCD,
+ signCount: 0,
+ aaguid: aaguidProduction,
+ credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+
+ verifier := s.newVerifier(chain.rootPool)
+ ok, svcErr := verifier.Verify(context.Background(), appleConfig(), token)
+ s.True(ok)
+ s.Nil(svcErr)
+}
+
+func (s *AppAttestVerifierTestSuite) TestVerify_DevelopmentAAGUIDAccepted() {
+ chain := s.generateTestChain()
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID),
+ flags: authDataFlagAttestedCD,
+ signCount: 0,
+ aaguid: aaguidDevelopment,
+ credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+
+ verifier := s.newVerifier(chain.rootPool)
+ ok, svcErr := verifier.Verify(context.Background(), appleConfig(), token)
+ s.True(ok)
+ s.Nil(svcErr)
+}
+
+// A missing or empty attestation configuration is an operational error, not a token rejection.
+func (s *AppAttestVerifierTestSuite) TestVerify_NotConfigured() {
+ chain := s.generateTestChain()
+ verifier := s.newVerifier(chain.rootPool)
+
+ s.assertOperationalError(verifier, nil, "anything")
+ s.assertOperationalError(verifier, &providers.AttestationConfig{}, "anything")
+}
+
+// An incomplete configuration (missing Team ID or Bundle ID) is an operational error.
+func (s *AppAttestVerifierTestSuite) TestVerify_IncompleteConfig() {
+ chain := s.generateTestChain()
+ verifier := s.newVerifier(chain.rootPool)
+
+ cases := map[string]*providers.AppleAttestationConfig{
+ "missing team id": {BundleID: testBundleID},
+ "missing bundle id": {TeamID: testTeamID},
+ }
+ for name, apple := range cases {
+ s.Run(name, func() {
+ s.assertOperationalError(verifier, &providers.AttestationConfig{Apple: apple}, "anything")
+ })
+ }
+}
+
+// A malformed or mismatched token is a definitive rejection, since App Attest is verified offline.
+func (s *AppAttestVerifierTestSuite) TestVerify_InvalidPayload() {
+ chain := s.generateTestChain()
+ verifier := s.newVerifier(chain.rootPool)
+
+ s.Run("not base64", func() {
+ s.assertRejected(verifier, appleConfig(), "***not-base64***")
+ })
+
+ s.Run("not cbor", func() {
+ token := base64.StdEncoding.EncodeToString([]byte("not cbor"))
+ s.assertRejected(verifier, appleConfig(), token)
+ })
+
+ s.Run("wrong format", func() {
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: authDataFlagAttestedCD,
+ aaguid: aaguidProduction, credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken("packed", [][]byte{chain.leafDER}, buildAuthData(opts))
+ s.assertRejected(verifier, appleConfig(), token)
+ })
+
+ s.Run("missing x5c", func() {
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: authDataFlagAttestedCD,
+ aaguid: aaguidProduction, credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, nil, buildAuthData(opts))
+ s.assertRejected(verifier, appleConfig(), token)
+ })
+
+ s.Run("attested credential data flag not set", func() {
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: 0x00,
+ aaguid: aaguidProduction, credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+ s.assertRejected(verifier, appleConfig(), token)
+ })
+
+ s.Run("truncated authData does not panic", func() {
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: authDataFlagAttestedCD,
+ aaguid: aaguidProduction, credentialID: s.credentialIDFor(chain.leafCert),
+ truncateTo: 10,
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+ s.NotPanics(func() {
+ s.assertRejected(verifier, appleConfig(), token)
+ })
+ })
+}
+
+func (s *AppAttestVerifierTestSuite) TestVerify_CertificateChainInvalid() {
+ chain := s.generateTestChain()
+ otherChain := s.generateTestChain() // untrusted root, not in verifier's pool
+
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: authDataFlagAttestedCD,
+ aaguid: aaguidProduction, credentialID: s.credentialIDFor(otherChain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{otherChain.leafDER}, buildAuthData(opts))
+
+ verifier := s.newVerifier(chain.rootPool)
+ s.assertRejected(verifier, appleConfig(), token)
+}
+
+func (s *AppAttestVerifierTestSuite) TestVerify_AppIdentifierMismatch() {
+ chain := s.generateTestChain()
+ opts := authDataOpts{
+ rpIDHash: appIDHash("WRONGTEAM"), flags: authDataFlagAttestedCD,
+ aaguid: aaguidProduction, credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+
+ verifier := s.newVerifier(chain.rootPool)
+ s.assertRejected(verifier, appleConfig(), token)
+}
+
+func (s *AppAttestVerifierTestSuite) TestVerify_EnvironmentUnrecognized() {
+ chain := s.generateTestChain()
+ var bogusAAGUID [16]byte
+ copy(bogusAAGUID[:], "not-app-attest!!")
+
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: authDataFlagAttestedCD,
+ aaguid: bogusAAGUID, credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+
+ verifier := s.newVerifier(chain.rootPool)
+ s.assertRejected(verifier, appleConfig(), token)
+}
+
+func (s *AppAttestVerifierTestSuite) TestVerify_SignCountNonZero() {
+ chain := s.generateTestChain()
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: authDataFlagAttestedCD,
+ signCount: 1, aaguid: aaguidProduction, credentialID: s.credentialIDFor(chain.leafCert),
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+
+ verifier := s.newVerifier(chain.rootPool)
+ s.assertRejected(verifier, appleConfig(), token)
+}
+
+func (s *AppAttestVerifierTestSuite) TestVerify_KeyIdentifierMismatch() {
+ chain := s.generateTestChain()
+ tamperedCredentialID := make([]byte, 32) // all-zero, does not match the leaf's public key
+
+ opts := authDataOpts{
+ rpIDHash: appIDHash(testTeamID), flags: authDataFlagAttestedCD,
+ aaguid: aaguidProduction, credentialID: tamperedCredentialID,
+ }
+ token := s.buildToken(appAttestFormat, [][]byte{chain.leafDER}, buildAuthData(opts))
+
+ verifier := s.newVerifier(chain.rootPool)
+ s.assertRejected(verifier, appleConfig(), token)
+}
+
+func (s *AppAttestVerifierTestSuite) TestNewAppAttestVerifier_ParsesEmbeddedRoot() {
+ verifier, err := newAppAttestVerifier()
+ s.NoError(err)
+ s.NotNil(verifier)
+}
diff --git a/backend/internal/attestation/composite.go b/backend/internal/attestation/composite.go
new file mode 100644
index 0000000000..2a5491087b
--- /dev/null
+++ b/backend/internal/attestation/composite.go
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package attestation
+
+import (
+ "context"
+
+ "github.com/thunder-id/thunderid/internal/system/log"
+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common"
+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
+)
+
+// compositeVerifier routes attestation verification to the platform provider that matches the
+// application's configuration. An application configures exactly one platform, so the platform is
+// determined by which sub-config is present.
+type compositeVerifier struct {
+ android providers.AttestationProvider
+ apple providers.AttestationProvider
+ logger *log.Logger
+}
+
+// newCompositeVerifier creates a platform-dispatching attestation provider.
+func newCompositeVerifier(android, apple providers.AttestationProvider) providers.AttestationProvider {
+ return &compositeVerifier{
+ android: android,
+ apple: apple,
+ logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "AttestationVerifier")),
+ }
+}
+
+// Verify dispatches to the Android (Play Integrity) or Apple (App Attest) verifier based on the
+// configured platform. A configuration with no platform set is an operational error.
+func (c *compositeVerifier) Verify(ctx context.Context, cfg *providers.AttestationConfig, token string) (
+ bool, *tidcommon.ServiceError) {
+ switch {
+ case cfg != nil && cfg.Android != nil:
+ return c.android.Verify(ctx, cfg, token)
+ case cfg != nil && cfg.Apple != nil:
+ return c.apple.Verify(ctx, cfg, token)
+ default:
+ c.logger.Error(ctx, "Attestation requested without a platform configuration")
+ return false, &tidcommon.InternalServerError
+ }
+}
diff --git a/backend/internal/attestation/composite_test.go b/backend/internal/attestation/composite_test.go
new file mode 100644
index 0000000000..950b1f917c
--- /dev/null
+++ b/backend/internal/attestation/composite_test.go
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package attestation
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/suite"
+
+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common"
+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
+ "github.com/thunder-id/thunderid/tests/mocks/attestationprovidermock"
+)
+
+type CompositeVerifierTestSuite struct {
+ suite.Suite
+ android *attestationprovidermock.AttestationProviderMock
+ apple *attestationprovidermock.AttestationProviderMock
+ verifier providers.AttestationProvider
+}
+
+func TestCompositeVerifierTestSuite(t *testing.T) {
+ suite.Run(t, new(CompositeVerifierTestSuite))
+}
+
+func (s *CompositeVerifierTestSuite) SetupTest() {
+ s.android = attestationprovidermock.NewAttestationProviderMock(s.T())
+ s.apple = attestationprovidermock.NewAttestationProviderMock(s.T())
+ s.verifier = newCompositeVerifier(s.android, s.apple)
+}
+
+// An Android-configured request dispatches to the Android provider only.
+func (s *CompositeVerifierTestSuite) TestVerify_DispatchesToAndroid() {
+ cfg := &providers.AttestationConfig{Android: &providers.AndroidAttestationConfig{PackageName: "com.example.app"}}
+ s.android.EXPECT().Verify(mock.Anything, cfg, "android-token").Return(true, nil)
+
+ ok, svcErr := s.verifier.Verify(context.Background(), cfg, "android-token")
+
+ s.True(ok)
+ s.Nil(svcErr)
+ s.apple.AssertNotCalled(s.T(), "Verify", mock.Anything, mock.Anything, mock.Anything)
+}
+
+// An Apple-configured request dispatches to the Apple provider only.
+func (s *CompositeVerifierTestSuite) TestVerify_DispatchesToApple() {
+ cfg := &providers.AttestationConfig{
+ Apple: &providers.AppleAttestationConfig{TeamID: "T1", BundleID: "com.example.app"},
+ }
+ s.apple.EXPECT().Verify(mock.Anything, cfg, "apple-token").Return(true, nil)
+
+ ok, svcErr := s.verifier.Verify(context.Background(), cfg, "apple-token")
+
+ s.True(ok)
+ s.Nil(svcErr)
+ s.android.AssertNotCalled(s.T(), "Verify", mock.Anything, mock.Anything, mock.Anything)
+}
+
+// A definitive rejection or operational error from the dispatched platform provider is passed
+// through unchanged.
+func (s *CompositeVerifierTestSuite) TestVerify_PropagatesProviderResult() {
+ cfg := &providers.AttestationConfig{Android: &providers.AndroidAttestationConfig{PackageName: "com.example.app"}}
+
+ s.Run("definitive rejection", func() {
+ s.SetupTest()
+ s.android.EXPECT().Verify(mock.Anything, cfg, "bad-token").Return(false, nil)
+
+ ok, svcErr := s.verifier.Verify(context.Background(), cfg, "bad-token")
+
+ s.False(ok)
+ s.Nil(svcErr)
+ })
+
+ s.Run("operational error", func() {
+ s.SetupTest()
+ s.android.EXPECT().Verify(mock.Anything, cfg, "any-token").Return(false, &tidcommon.InternalServerError)
+
+ ok, svcErr := s.verifier.Verify(context.Background(), cfg, "any-token")
+
+ s.False(ok)
+ s.Equal(&tidcommon.InternalServerError, svcErr)
+ })
+}
+
+// A configuration with neither platform set (nil, or an empty AttestationConfig) is an operational
+// error; neither platform provider is called since there is nothing to dispatch to.
+func (s *CompositeVerifierTestSuite) TestVerify_NoConfiguredPlatform() {
+ cases := map[string]*providers.AttestationConfig{
+ "nil config": nil,
+ "empty config": {},
+ }
+ for name, cfg := range cases {
+ s.Run(name, func() {
+ s.SetupTest()
+
+ ok, svcErr := s.verifier.Verify(context.Background(), cfg, "anything")
+
+ s.False(ok)
+ s.Equal(&tidcommon.InternalServerError, svcErr)
+ s.android.AssertNotCalled(s.T(), "Verify", mock.Anything, mock.Anything, mock.Anything)
+ s.apple.AssertNotCalled(s.T(), "Verify", mock.Anything, mock.Anything, mock.Anything)
+ })
+ }
+}
diff --git a/backend/internal/attestation/error_constants.go b/backend/internal/attestation/error_constants.go
index c6c64aa635..591bd7e21b 100644
--- a/backend/internal/attestation/error_constants.go
+++ b/backend/internal/attestation/error_constants.go
@@ -33,4 +33,19 @@ var (
// errAppNotPlayRecognized is returned when Play Integrity does not recognize the app as a
// genuine, Play-distributed binary.
errAppNotPlayRecognized = errors.New("app is not recognized by Google Play")
+
+ // errCertificateChainInvalid is returned when an App Attest attestation certificate chain does
+ // not validate against Apple's App Attest root certificate authority.
+ errCertificateChainInvalid = errors.New("attestation certificate chain is invalid")
+ // errAppIdentifierMismatch is returned when the attested App ID does not match the registered
+ // Team ID and Bundle ID.
+ errAppIdentifierMismatch = errors.New("attested app identifier does not match the registered identity")
+ // errKeyIdentifierMismatch is returned when the attested credential ID does not match the hash of
+ // the attestation certificate's public key.
+ errKeyIdentifierMismatch = errors.New("attested credential identifier does not match the certificate key")
+ // errEnvironmentUnrecognized is returned when the App Attest AAGUID identifies neither the
+ // production nor the development environment.
+ errEnvironmentUnrecognized = errors.New("attestation environment is not recognized")
+ // errSignCountNonZero is returned when a fresh App Attest key reports a non-zero signature count.
+ errSignCountNonZero = errors.New("attestation signature count is not zero")
)
diff --git a/backend/internal/attestation/init.go b/backend/internal/attestation/init.go
index e21aa48a1e..27aeacadf8 100644
--- a/backend/internal/attestation/init.go
+++ b/backend/internal/attestation/init.go
@@ -23,7 +23,17 @@ import (
"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
)
-// Initialize creates the platform attestation provider backed by the Google Play Integrity API.
-func Initialize(cryptoSvc kmprovider.RuntimeCryptoProvider) providers.AttestationProvider {
- return newPlayIntegrityVerifier(newGooglePlayIntegrityDecoder(), cryptoSvc)
+// Initialize creates the platform attestation provider, dispatching to Google Play Integrity for
+// Android clients and Apple App Attest for iOS clients based on the application's configuration. It
+// returns an error if a platform verifier cannot be constructed, so the caller can fail server
+// startup rather than run with a non-functional verifier.
+func Initialize(cryptoSvc kmprovider.RuntimeCryptoProvider) (providers.AttestationProvider, error) {
+ appAttestVsvc, err := newAppAttestVerifier()
+ if err != nil {
+ return nil, err
+ }
+ return newCompositeVerifier(
+ newPlayIntegrityVerifier(newGooglePlayIntegrityDecoder(), cryptoSvc),
+ appAttestVsvc,
+ ), nil
}
diff --git a/backend/internal/attestation/model.go b/backend/internal/attestation/model.go
new file mode 100644
index 0000000000..5be4b5ea0a
--- /dev/null
+++ b/backend/internal/attestation/model.go
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package attestation
+
+// attestationObject is the CBOR-encoded structure produced by DCAppAttestService.attestKey.
+type attestationObject struct {
+ Fmt string `cbor:"fmt"`
+ AttStmt struct {
+ X5C [][]byte `cbor:"x5c"`
+ // Receipt is an App Store receipt unrelated to binary identity; intentionally unused.
+ Receipt []byte `cbor:"receipt"`
+ } `cbor:"attStmt"`
+ AuthData []byte `cbor:"authData"`
+}
+
+// parsedAuthData holds the authenticator data fields relevant to attestation verification.
+type parsedAuthData struct {
+ rpIDHash []byte
+ signCount uint32
+ aaguid [16]byte
+ credentialID []byte
+}
diff --git a/backend/internal/flow/flowexec/service.go b/backend/internal/flow/flowexec/service.go
index e43dd31e6d..847d12f930 100644
--- a/backend/internal/flow/flowexec/service.go
+++ b/backend/internal/flow/flowexec/service.go
@@ -311,7 +311,7 @@ func (s *flowExecService) resolveFlowInitiationMode(
if clientErr != nil {
return 0, nil, clientErr
}
- if client.Attestation != nil && client.Attestation.Android != nil {
+ if client.Attestation != nil && (client.Attestation.Android != nil || client.Attestation.Apple != nil) {
return flowInitiationAttestation, client.Attestation, nil
}
diff --git a/backend/internal/flow/flowexec/service_test.go b/backend/internal/flow/flowexec/service_test.go
index 5b8b58d069..ea709403c8 100644
--- a/backend/internal/flow/flowexec/service_test.go
+++ b/backend/internal/flow/flowexec/service_test.go
@@ -2852,6 +2852,39 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AttestationValid
s.Nil(svcErr)
}
+// appleAttestationClient returns an inbound client configured with Apple App Attest attestation.
+func appleAttestationClient() *providers.InboundClient {
+ return &providers.InboundClient{
+ ID: "mobile-app",
+ Attestation: &providers.AttestationConfig{
+ Apple: &providers.AppleAttestationConfig{TeamID: "TEAM123", BundleID: "com.example.app"},
+ },
+ }
+}
+
+// An Apple-configured client also resolves to attestation-based flow initiation, and a token verified
+// by the provider permits direct flow initiation.
+func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AppleAttestationValid() {
+ t := s.T()
+ mockActorProvider := actorprovidermock.NewActorProviderMock(t)
+ mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "mobile-app").Return(
+ appleAttestationClient(), nil)
+ mockProvider := attestationprovidermock.NewAttestationProviderMock(t)
+ mockProvider.EXPECT().Verify(mock.Anything, mock.MatchedBy(func(cfg *providers.AttestationConfig) bool {
+ return cfg != nil && cfg.Apple != nil && cfg.Apple.TeamID == "TEAM123"
+ }), "good-token").Return(true, nil)
+
+ service := &flowExecService{
+ actorProvider: mockActorProvider,
+ attestationVerifier: mockProvider,
+ cfg: testFlowExecCfg,
+ }
+
+ svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "mobile-app",
+ providers.FlowTypeAuthentication, "", "good-token", log.GetLogger())
+ s.Nil(svcErr)
+}
+
// --- getFlowContext ---
func (s *ServiceTestSuite) TestGetFlowContext_NilDbModel() {
diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go
index b3d4d307dc..5d67bbb750 100644
--- a/backend/internal/system/i18n/core/defaults.go
+++ b/backend/internal/system/i18n/core/defaults.go
@@ -141,6 +141,8 @@ var defaultMessages = map[string]string{
"error.agentservice.userinfo_unsupported_encryption_enc_description": "userinfo content-encryption algorithm is not supported",
"error.agentservice.userinfo_unsupported_response_type_description": "userinfo responseType is not supported",
"error.agentservice.userinfo_unsupported_signing_alg_description": "userinfo signing algorithm is not supported",
+ "error.applicationservice.ambiguous_attestation_config": "Attestation configuration must set exactly one platform",
+ "error.applicationservice.ambiguous_attestation_config_description": "An application's attestation configuration may configure only one platform (android or apple) at a time",
"error.applicationservice.application_already_exists": "Application already exists",
"error.applicationservice.application_already_exists_description": "An application with the same name already exists",
"error.applicationservice.application_is_nil": "Application is nil",
diff --git a/backend/pkg/thunderidengine/engine.go b/backend/pkg/thunderidengine/engine.go
index 95c76ada1d..d3f69d8bd5 100644
--- a/backend/pkg/thunderidengine/engine.go
+++ b/backend/pkg/thunderidengine/engine.go
@@ -139,7 +139,10 @@ func New(mux *http.ServeMux, opts ...Option) *Engine {
engineCtx.graphBuilder = graphbuilder.Initialize(engineCtx.flowFactory, engineCtx.execRegistry,
engineCtx.interceptorRegistry, graphCache)
- attestationProvider := attestation.Initialize(engineCtx.runtimeCryptoSvc)
+ attestationProvider, err := attestation.Initialize(engineCtx.runtimeCryptoSvc)
+ if err != nil {
+ logger.Fatal(ctx, "Failed to initialize attestation provider", log.Error(err))
+ }
flowExecService, err := flowexec.Initialize(mux, engineCtx.flowProvider, engineCtx.actorProvider,
engineCtx.execRegistry, engineCtx.interceptorRegistry, engineCtx.observabilitySvc,
engineCtx.runtimeCryptoSvc, attestationProvider, engineCtx.graphBuilder, runtimeStoreProvider,
diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go
index 8429df8cee..286c8c9764 100644
--- a/backend/pkg/thunderidengine/providers/model.go
+++ b/backend/pkg/thunderidengine/providers/model.go
@@ -648,6 +648,7 @@ type Certificate struct {
// identity of a mobile client when it initiates a flow directly over HTTP.
type AttestationConfig struct {
Android *AndroidAttestationConfig `json:"android,omitempty" yaml:"android,omitempty" jsonschema:"Google Play Integrity attestation configuration for Android clients."`
+ Apple *AppleAttestationConfig `json:"apple,omitempty" yaml:"apple,omitempty" jsonschema:"Apple App Attest attestation configuration for iOS clients."`
}
// AndroidAttestationConfig holds the Google Play Integrity settings for an Android application.
@@ -657,6 +658,12 @@ type AndroidAttestationConfig struct {
ServiceAccountCredentials string `json:"serviceAccountCredentials,omitempty" yaml:"serviceAccountCredentials,omitempty" jsonschema:"Google Cloud service account credentials (JSON) used to call the Play Integrity API. Write-only; never returned in responses."`
}
+// AppleAttestationConfig holds the Apple App Attest settings for an iOS application.
+type AppleAttestationConfig struct {
+ TeamID string `json:"teamId,omitempty" yaml:"teamId,omitempty" jsonschema:"Apple Developer Team ID."`
+ BundleID string `json:"bundleId,omitempty" yaml:"bundleId,omitempty" jsonschema:"iOS application bundle identifier that must match the attested app."`
+}
+
// WithoutCredentials returns a deep copy of the attestation config with all write-only secrets
// removed, safe to include in API responses. Returns nil for a nil receiver.
func (c *AttestationConfig) WithoutCredentials() *AttestationConfig {
@@ -670,6 +677,10 @@ func (c *AttestationConfig) WithoutCredentials() *AttestationConfig {
android.CertificateSha256Digests = append([]string(nil), c.Android.CertificateSha256Digests...)
sanitized.Android = &android
}
+ if c.Apple != nil {
+ apple := *c.Apple
+ sanitized.Apple = &apple
+ }
return sanitized
}
diff --git a/backend/pkg/thunderidengine/providers/model_test.go b/backend/pkg/thunderidengine/providers/model_test.go
index e79f0d1f60..8cc1f79918 100644
--- a/backend/pkg/thunderidengine/providers/model_test.go
+++ b/backend/pkg/thunderidengine/providers/model_test.go
@@ -348,3 +348,39 @@ func (suite *ModelTestSuite) TestNodeContext_ConsumeInput_AccumulatesAcrossCalls
assert.Equal(suite.T(), []string{"a", "c", "b"}, nc.GetConsumedInputs())
}
+
+// ----- AttestationConfig -----
+
+func (suite *ModelTestSuite) TestAttestationConfig_WithoutCredentials_NilReceiver() {
+ var cfg *AttestationConfig
+ assert.Nil(suite.T(), cfg.WithoutCredentials())
+}
+
+func (suite *ModelTestSuite) TestAttestationConfig_WithoutCredentials_StripsAndroidSecret() {
+ cfg := &AttestationConfig{
+ Android: &AndroidAttestationConfig{
+ PackageName: "com.example.app",
+ CertificateSha256Digests: []string{"AA:BB"},
+ ServiceAccountCredentials: "secret",
+ },
+ }
+
+ sanitized := cfg.WithoutCredentials()
+
+ assert.Equal(suite.T(), "com.example.app", sanitized.Android.PackageName)
+ assert.Equal(suite.T(), []string{"AA:BB"}, sanitized.Android.CertificateSha256Digests)
+ assert.Empty(suite.T(), sanitized.Android.ServiceAccountCredentials)
+}
+
+func (suite *ModelTestSuite) TestAttestationConfig_WithoutCredentials_PassesApplePassThrough() {
+ cfg := &AttestationConfig{
+ Apple: &AppleAttestationConfig{TeamID: "TEAM123", BundleID: "com.example.app"},
+ }
+
+ sanitized := cfg.WithoutCredentials()
+
+ assert.NotNil(suite.T(), sanitized.Apple)
+ assert.Equal(suite.T(), "TEAM123", sanitized.Apple.TeamID)
+ assert.Equal(suite.T(), "com.example.app", sanitized.Apple.BundleID)
+ assert.Nil(suite.T(), sanitized.Android)
+}
diff --git a/docs/content/guides/guides/applications/application-settings.mdx b/docs/content/guides/guides/applications/application-settings.mdx
index 846e84a117..86138bfe9f 100644
--- a/docs/content/guides/guides/applications/application-settings.mdx
+++ b/docs/content/guides/guides/applications/application-settings.mdx
@@ -130,9 +130,13 @@ You can also attach a **Certificate** for client authentication (`private_key_jw
For full protocol-level details, including ACR values, scope-to-claims mapping, PAR enforcement, and token signing and encryption algorithms, see the [OAuth & OIDC](../../protocols/oauth-oidc/) catalogue.
-## Configure Play Integrity Attestation
+## Configure Platform Attestation
-Mobile applications cannot safely hold a Flow Secret, so they prove their identity a different way: by presenting a Google Play Integrity token. See [Attestation](../../key-concepts/authentication/integration-models.mdx#attestation) for how this fits into the App-Native integration model. Configure it from the application's **Advanced** tab, or via the top-level `attestation.android` block in a declarative resource:
+Mobile applications cannot safely hold a Flow Secret, so they prove their identity a different way: by presenting a platform attestation token. supports Google Play Integrity for Android clients and Apple App Attest for iOS clients. See [Attestation](../../key-concepts/authentication/integration-models.mdx#attestation) for how this fits into the App-Native integration model. Attestation is optional; when enabled, an application configures exactly one platform, from its **Advanced** tab or via the top-level `attestation` block in a declarative resource.
+
+### Android (Google Play Integrity)
+
+Configure the Android platform under the `attestation.android` block:
| Setting | Description |
|---------|-------------|
@@ -141,9 +145,18 @@ Mobile applications cannot safely hold a Flow Secret, so they prove their identi
| **Service Account Credentials** | The Google Cloud service account JSON used to call the Play Integrity API. It is **write-only**: never returns it in responses, and leaving it blank when editing keeps the stored value. |
:::note
-The service account credentials are stored encrypted and never returned. For an app managed declaratively, set the `serviceAccountCredentials` field in the top-level `attestation.android` block; on update, omit it to preserve the previously stored value.
+The service account credentials are stored encrypted and never returned. For an app managed declaratively, set the `serviceAccountCredentials` field in the `attestation.android` block. On update, omit it to preserve the previously stored value.
:::
+### iOS (Apple App Attest)
+
+Configure the iOS platform under the `attestation.apple` block. Verification runs entirely on the server: validates the attestation certificate chain against Apple's App Attest root certificate authority, then checks the attested app against the registered identity. This platform uses only non-secret identifiers, so nothing is stored write-only.
+
+| Setting | Description |
+|---------|-------------|
+| **Team ID** | The Apple Developer Team ID (for example, `ABCDE12345`). |
+| **Bundle ID** | The iOS application bundle identifier (for example, `com.example.myapp`) that must match the attested app. |
+
## Related Guides
- [Manage Applications](../manage-applications) - Create, update, and delete applications
diff --git a/docs/content/guides/key-concepts/authentication/integration-models.mdx b/docs/content/guides/key-concepts/authentication/integration-models.mdx
index 5a992adf76..4ea135dcc3 100644
--- a/docs/content/guides/key-concepts/authentication/integration-models.mdx
+++ b/docs/content/guides/key-concepts/authentication/integration-models.mdx
@@ -82,16 +82,16 @@ See the [API Reference](../../../../apis#tag/flow-execution) for endpoint specif
### Attestation
-A confidential server-side application authenticates at flow initiation with a Flow Secret, but a mobile application cannot safely hold one. Instead, a mobile client proves its identity through platform attestation: when an application configures **Play Integrity attestation**, it may initiate a sign-in flow directly by presenting a Google [Play Integrity](https://developer.android.com/google/play/integrity) token. verifies the token against Google's Play Integrity API and confirms that the attested app matches the registered package name and signing certificate before starting the flow.
+A confidential server-side application authenticates at flow initiation with a Flow Secret, but a mobile application cannot safely hold one. Instead, a mobile client proves its identity through platform attestation. supports Google [Play Integrity](https://developer.android.com/google/play/integrity) for Android clients and Apple [App Attest](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server) for iOS clients. When an application configures attestation, it may initiate a sign-in flow directly by presenting a platform attestation token, which verifies against the registered identity before starting the flow.
-Attestation is a client-level setting configured at the application level, independent of the OAuth 2.0 protocol, so it applies to any application type, including embedded apps with no OAuth 2.0 configuration. When attestation is configured, it takes precedence: the application must present a valid attestation token to initiate a flow, and any Flow Secret is ignored. See [Configure Play Integrity Attestation](../../guides/applications/application-settings.mdx#configure-play-integrity-attestation) for how to configure it.
+Attestation is a client-level setting configured at the application level, independent of the OAuth 2.0 protocol, so it applies to any application type, including embedded apps with no OAuth 2.0 configuration. When attestation is configured, it takes precedence: the application must present a valid attestation token to initiate a flow, and any Flow Secret is ignored. Attestation is optional; when enabled, an application configures exactly one platform. See [Configure Platform Attestation](../../guides/applications/application-settings.mdx#configure-platform-attestation) for how to configure it.
-To initiate a new flow, the mobile client requests a Play Integrity token from Google and presents it in the `Attestation-Token` request header of the Flow Execution request:
+To initiate a new flow, the mobile client obtains a platform attestation token and presents it in the `Attestation-Token` request header of the Flow Execution request:
```http
POST /flow/execute
Content-Type: application/json
-Attestation-Token:
+Attestation-Token:
{
"applicationId": "",
@@ -99,7 +99,7 @@ Attestation-Token:
}
```
-An attested mobile app that omits the token is rejected with `401 Unauthorized`. A token that fails verification is also rejected with `401 Unauthorized`. Verification fails, for example, on a wrong package name, an unmatched signing certificate, or an app not recognized by Google Play. Flow **continuation** requests (those carrying an `executionId`) do not require an attestation token.
+An attested mobile app that omits the token is rejected with `401 Unauthorized`, and an invalid or malformed token is also rejected with `401 Unauthorized`. Flow **continuation** requests (those carrying an `executionId`) do not require an attestation token.
### Integration Modes
diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx
index 285d5e9c9a..8794c56dfc 100644
--- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx
+++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx
@@ -17,18 +17,35 @@
*/
import {SettingsCard} from '@thunderid/components';
-import {Box, Button, FormControl, FormLabel, IconButton, Stack, TextField, Tooltip, Typography} from '@wso2/oxygen-ui';
+import {
+ Autocomplete,
+ Box,
+ Button,
+ FormControl,
+ FormLabel,
+ IconButton,
+ Stack,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@wso2/oxygen-ui';
import {Plus, Trash} from '@wso2/oxygen-ui-icons-react';
import {useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import type {AttestationConfig} from '../../../models/oauth';
+/**
+ * The attestation platform an application is configured for. An application configures exactly one
+ * platform; 'none' means attestation is disabled.
+ */
+type AttestationPlatform = 'none' | 'android' | 'apple';
+
/**
* Props for the {@link AttestationSection} component.
*/
interface AttestationSectionProps {
/**
- * The current attestation config (from the OAuth config).
+ * The current attestation config.
* null or undefined means no attestation is configured.
*/
attestation?: AttestationConfig | null;
@@ -41,15 +58,36 @@ interface AttestationSectionProps {
* Whether inputs should be disabled (e.g. read-only resource).
*/
disabled?: boolean;
+ /**
+ * Called whenever the section's validation state changes, so the parent can block Save while an
+ * incomplete apple config exists. true means the section currently has a validation error.
+ */
+ onValidationChange?: (hasErrors: boolean) => void;
}
/**
- * Section component for configuring Google Play Integrity attestation for Android mobile clients.
+ * Derives the configured platform from an attestation config.
+ */
+function platformOf(attestation?: AttestationConfig | null): AttestationPlatform {
+ if (attestation?.apple) {
+ return 'apple';
+ }
+ if (attestation?.android) {
+ return 'android';
+ }
+ return 'none';
+}
+
+/**
+ * Section component for configuring platform attestation for mobile clients.
*
* A mobile application that configures attestation may initiate an authentication flow directly by
- * presenting a Play Integrity token, which the server verifies against the registered package name
- * and signing certificate digests. The service account credentials are write-only: they are never
- * returned by the API, so leaving the field blank when editing preserves the stored value.
+ * presenting an attestation token, which the server verifies against the registered identity —
+ * Google Play Integrity for Android (package name + signing certificate digests, with write-only
+ * service account credentials) or Apple App Attest for iOS (Team ID + Bundle ID). An application
+ * configures exactly one platform, so the platform selector switches between the two field sets.
+ * Apple's Team ID and Bundle ID are required together: a config with only one of the two is never
+ * emitted to the parent, since the backend cannot verify an incomplete identity.
*
* @param props - Component props
* @returns Attestation configuration UI within a SettingsCard
@@ -58,23 +96,37 @@ export default function AttestationSection({
attestation = undefined,
onAttestationChange,
disabled = false,
+ onValidationChange = undefined,
}: AttestationSectionProps) {
const {t} = useTranslation();
const android = attestation?.android;
+ const apple = attestation?.apple;
+ const propPlatform = platformOf(attestation);
+
// The fields are backed by local state (seeded from props) so editing is never gated on the
// parent's config round-trip. Credentials are write-only and never seeded from props.
+ const [platform, setPlatform] = useState(propPlatform);
const [packageName, setPackageName] = useState(android?.packageName ?? '');
const [digests, setDigests] = useState(android?.certificateSha256Digests ?? []);
const [credentials, setCredentials] = useState('');
+ const [teamId, setTeamId] = useState(apple?.teamId ?? '');
+ const [bundleId, setBundleId] = useState(apple?.bundleId ?? '');
+
+ // Canonical identity of the incoming config (platform + non-secret fields). The effect below
+ // resyncs local state when the attestation prop is replaced externally — e.g. the application
+ // reloads, or the config is cleared — while ignoring the echo of this component's own emissions
+ // (tracked via the ref). Credentials are write-only and never part of the identity.
+ const computeIdentity = (p: AttestationPlatform, pkg: string, digs: string[], team: string, bundle: string) =>
+ JSON.stringify({platform: p, packageName: pkg, digests: digs, teamId: team, bundleId: bundle});
- // Identity of the incoming config (package name + digests). The effect below resyncs local state
- // when the attestation prop is replaced externally — e.g. the application reloads, or the config
- // is cleared — while ignoring the echo of this component's own emissions (tracked via the ref).
- const identityKey = JSON.stringify({
- packageName: android?.packageName ?? '',
- digests: android?.certificateSha256Digests ?? [],
- });
+ const identityKey = computeIdentity(
+ propPlatform,
+ android?.packageName ?? '',
+ android?.certificateSha256Digests ?? [],
+ apple?.teamId ?? '',
+ apple?.bundleId ?? '',
+ );
const lastSyncedKeyRef = useRef(identityKey);
useEffect(() => {
@@ -82,53 +134,121 @@ export default function AttestationSection({
return;
}
lastSyncedKeyRef.current = identityKey;
+ setPlatform(propPlatform);
setPackageName(android?.packageName ?? '');
setDigests(android?.certificateSha256Digests ?? []);
+ setTeamId(apple?.teamId ?? '');
+ setBundleId(apple?.bundleId ?? '');
// Credentials are write-only; an external config change resets the editable field to blank.
setCredentials('');
- // identityKey is the canonical trigger; android is read for the values it encodes.
+ // identityKey is the canonical trigger; the config values are read for what it encodes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identityKey]);
- const emit = (pkg: string, digs: string[], creds: string) => {
+ // Apple's Team ID and Bundle ID are required together (the backend cannot verify a partial
+ // identity). While exactly one of the two is populated, the config is incomplete: emit() below
+ // skips the update entirely rather than propagating a partial (invalid) apple config, and this
+ // flag drives an inline validation hint on whichever field is still empty.
+ const appleIncomplete = platform === 'apple' && (teamId.trim() === '') !== (bundleId.trim() === '');
+
+ useEffect(() => {
+ onValidationChange?.(appleIncomplete);
+ }, [appleIncomplete, onValidationChange]);
+
+ const emit = (
+ nextPlatform: AttestationPlatform,
+ pkg: string,
+ digs: string[],
+ creds: string,
+ team: string,
+ bundle: string,
+ ) => {
const cleanedDigests = digs.map((d) => d.trim()).filter((d) => d !== '');
const cleanedPackageName = pkg.trim();
+ const cleanedTeamId = team.trim();
+ const cleanedBundleId = bundle.trim();
+
+ let config: AttestationConfig | null = null;
+ let identityPlatform: AttestationPlatform = 'none';
+
+ if (nextPlatform === 'android') {
+ const androidConfig: NonNullable = {};
+ if (cleanedPackageName !== '') {
+ androidConfig.packageName = cleanedPackageName;
+ }
+ if (cleanedDigests.length > 0) {
+ androidConfig.certificateSha256Digests = cleanedDigests;
+ }
+ if (creds !== '') {
+ androidConfig.serviceAccountCredentials = creds;
+ }
+ if (Object.keys(androidConfig).length > 0) {
+ config = {android: androidConfig};
+ identityPlatform = 'android';
+ }
+ } else if (nextPlatform === 'apple') {
+ if (cleanedTeamId !== '' && cleanedBundleId !== '') {
+ config = {apple: {teamId: cleanedTeamId, bundleId: cleanedBundleId}};
+ identityPlatform = 'apple';
+ } else if (cleanedTeamId !== '' || cleanedBundleId !== '') {
+ // Exactly one of the two is set: an incomplete apple config. Do not emit it — that would
+ // persist an identity the backend can never verify. Skip the update entirely so the
+ // parent keeps its last valid value (complete or cleared) while the user finishes
+ // entering the other field; appleIncomplete (above) surfaces a validation hint instead.
+ return;
+ }
+ // Both empty falls through with config left at null, clearing any stored apple config.
+ }
// Record the identity being emitted so the resync effect ignores the resulting prop echo and
// preserves the user's in-progress edits.
- lastSyncedKeyRef.current = JSON.stringify({packageName: cleanedPackageName, digests: cleanedDigests});
+ lastSyncedKeyRef.current = computeIdentity(
+ identityPlatform,
+ identityPlatform === 'android' ? cleanedPackageName : '',
+ identityPlatform === 'android' ? cleanedDigests : [],
+ identityPlatform === 'apple' ? cleanedTeamId : '',
+ identityPlatform === 'apple' ? cleanedBundleId : '',
+ );
+ onAttestationChange(config);
+ };
- if (cleanedPackageName === '' && cleanedDigests.length === 0 && creds === '') {
- onAttestationChange(null);
- return;
- }
+ const platformOptions: {value: AttestationPlatform; label: string}[] = [
+ {value: 'none', label: t('applications:edit.advanced.attestation.platform.none', 'None')},
+ {
+ value: 'android',
+ label: t('applications:edit.advanced.attestation.platform.android', 'Android (Play Integrity)'),
+ },
+ {value: 'apple', label: t('applications:edit.advanced.attestation.platform.apple', 'iOS (App Attest)')},
+ ];
- const androidConfig: NonNullable = {};
- if (cleanedPackageName !== '') {
- androidConfig.packageName = cleanedPackageName;
- }
- if (cleanedDigests.length > 0) {
- androidConfig.certificateSha256Digests = cleanedDigests;
- }
- if (creds !== '') {
- androidConfig.serviceAccountCredentials = creds;
- }
- onAttestationChange({android: androidConfig});
+ const handlePlatformChange = (next: AttestationPlatform) => {
+ setPlatform(next);
+ emit(next, packageName, digests, credentials, teamId, bundleId);
};
const handlePackageNameChange = (value: string) => {
setPackageName(value);
- emit(value, digests, credentials);
+ emit(platform, value, digests, credentials, teamId, bundleId);
};
const handleCredentialsChange = (value: string) => {
setCredentials(value);
- emit(packageName, digests, value);
+ emit(platform, packageName, digests, value, teamId, bundleId);
+ };
+
+ const handleTeamIdChange = (value: string) => {
+ setTeamId(value);
+ emit(platform, packageName, digests, credentials, value, bundleId);
+ };
+
+ const handleBundleIdChange = (value: string) => {
+ setBundleId(value);
+ emit(platform, packageName, digests, credentials, teamId, value);
};
const commitDigests = (nextDigests: string[]) => {
setDigests(nextDigests);
- emit(packageName, nextDigests, credentials);
+ emit(platform, packageName, nextDigests, credentials, teamId, bundleId);
};
const handleAddDigest = () => {
@@ -145,90 +265,196 @@ export default function AttestationSection({
return (
-
- {t('applications:edit.advanced.attestation.labels.packageName')}
+
+ {t('applications:edit.advanced.attestation.labels.platform', 'Platform')}
- handlePackageNameChange(e.target.value)}
- placeholder={t('applications:edit.advanced.attestation.placeholder.packageName')}
- helperText={t('applications:edit.advanced.attestation.hint.packageName')}
+ opt.value === platform) ?? platformOptions[0]}
+ onChange={(_, newValue) => handlePlatformChange(newValue?.value ?? 'none')}
+ options={platformOptions}
+ getOptionLabel={(option) => option.label}
+ isOptionEqualToValue={(option, value) => option.value === value.value}
+ renderInput={(params) => }
+ disableClearable
disabled={disabled}
/>
-
-
- {t('applications:edit.advanced.attestation.labels.certificateSha256Digests')}
-
-
- {t('applications:edit.advanced.attestation.hint.certificateSha256Digests')}
-
-
- {digests.map((digest, index) => (
- // IMPORTANT: Do not remove the suppression since it affects functionality.
- // eslint-disable-next-line react/no-array-index-key
-
-
- handleDigestChange(index, e.target.value)}
- onBlur={() => commitDigests(digests)}
- placeholder={t('applications:edit.advanced.attestation.placeholder.certificateSha256Digest')}
+ {platform === 'android' && (
+ <>
+
+
+ {t('applications:edit.advanced.attestation.labels.packageName', 'Package Name')}
+
+ handlePackageNameChange(e.target.value)}
+ placeholder={t('applications:edit.advanced.attestation.placeholder.packageName', 'com.example.myapp')}
+ helperText={t(
+ 'applications:edit.advanced.attestation.hint.packageName',
+ 'The Android application package name that must match the attested app.',
+ )}
+ disabled={disabled}
+ />
+
+
+
+
+ {t(
+ 'applications:edit.advanced.attestation.labels.certificateSha256Digests',
+ 'Signing Certificate SHA-256 Digests',
+ )}
+
+
+ {t(
+ 'applications:edit.advanced.attestation.hint.certificateSha256Digests',
+ 'Allowed signing certificate digests, in the URL-safe base64 form reported by Play Integrity. ' +
+ 'The attested app must match one of these.',
+ )}
+
+
+ {digests.map((digest, index) => (
+ // IMPORTANT: Do not remove the suppression since it affects functionality.
+ // eslint-disable-next-line react/no-array-index-key
+
+
+ handleDigestChange(index, e.target.value)}
+ onBlur={() => commitDigests(digests)}
+ placeholder={t(
+ 'applications:edit.advanced.attestation.placeholder.certificateSha256Digest',
+ 'URL-safe base64 SHA-256 digest',
+ )}
+ disabled={disabled}
+ />
+
+
+ handleRemoveDigest(index)}
+ color="error"
+ sx={{mt: 1}}
+ disabled={disabled}
+ >
+
+
+
+
+ ))}
+
+ }
+ onClick={handleAddDigest}
+ size="small"
disabled={disabled}
- />
-
-
- handleRemoveDigest(index)} color="error" sx={{mt: 1}} disabled={disabled}>
-
-
-
+ >
+ {t('applications:edit.advanced.attestation.addDigest', 'Add Digest')}
+
+
- ))}
-
- }
- onClick={handleAddDigest}
- size="small"
+
+
+
+
+ {t(
+ 'applications:edit.advanced.attestation.labels.serviceAccountCredentials',
+ 'Service Account Credentials',
+ )}
+
+ handleCredentialsChange(e.target.value)}
+ placeholder={t(
+ 'applications:edit.advanced.attestation.placeholder.serviceAccountCredentials',
+ 'Paste the Google Cloud service account JSON',
+ )}
+ helperText={t(
+ 'applications:edit.advanced.attestation.hint.serviceAccountCredentials',
+ 'Write-only. Used to call the Play Integrity API. Leave blank to keep the existing credentials.',
+ )}
disabled={disabled}
- >
- {t('applications:edit.advanced.attestation.addDigest')}
-
-
-
-
+ />
+
+ >
+ )}
-
-
- {t('applications:edit.advanced.attestation.labels.serviceAccountCredentials')}
-
- handleCredentialsChange(e.target.value)}
- placeholder={t('applications:edit.advanced.attestation.placeholder.serviceAccountCredentials')}
- helperText={t('applications:edit.advanced.attestation.hint.serviceAccountCredentials')}
- disabled={disabled}
- />
-
+ {platform === 'apple' && (
+ <>
+
+
+ {t('applications:edit.advanced.attestation.labels.teamId', 'Team ID')}
+
+ handleTeamIdChange(e.target.value)}
+ placeholder={t('applications:edit.advanced.attestation.placeholder.teamId', 'ABCDE12345')}
+ error={appleIncomplete && teamId.trim() === ''}
+ helperText={
+ appleIncomplete && teamId.trim() === ''
+ ? t(
+ 'applications:edit.advanced.attestation.error.appleIncomplete',
+ 'Both Team ID and Bundle ID are required together.',
+ )
+ : t('applications:edit.advanced.attestation.hint.teamId', 'The Apple Developer Team ID.')
+ }
+ disabled={disabled}
+ />
+
+
+
+
+ {t('applications:edit.advanced.attestation.labels.bundleId', 'Bundle ID')}
+
+ handleBundleIdChange(e.target.value)}
+ placeholder={t('applications:edit.advanced.attestation.placeholder.bundleId', 'com.example.myapp')}
+ error={appleIncomplete && bundleId.trim() === ''}
+ helperText={
+ appleIncomplete && bundleId.trim() === ''
+ ? t(
+ 'applications:edit.advanced.attestation.error.appleIncomplete',
+ 'Both Team ID and Bundle ID are required together.',
+ )
+ : t(
+ 'applications:edit.advanced.attestation.hint.bundleId',
+ 'The iOS bundle identifier that must match the attested app.',
+ )
+ }
+ disabled={disabled}
+ />
+
+ >
+ )}
);
diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx
index d33446b368..57bc06d2db 100644
--- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx
+++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx
@@ -17,6 +17,7 @@
*/
import {Stack} from '@wso2/oxygen-ui';
+import {useEffect, useState} from 'react';
import AttestationSection from './AttestationSection';
import CertificateSection from './CertificateSection';
import IdentityAssertionsSection from './IdentityAssertionsSection';
@@ -64,8 +65,8 @@ interface EditAdvancedSettingsProps {
*/
showAttestation?: boolean;
/**
- * Callback to report whether the identity assertions (ID-JAG) section currently has
- * validation errors (feeds the Save bar).
+ * Callback to report whether any child section (identity assertions / ID-JAG, or platform
+ * attestation) currently has validation errors (feeds the page's Save bar).
*/
onValidationChange?: (hasErrors: boolean) => void;
}
@@ -93,6 +94,16 @@ export default function EditAdvancedSettings({
showAttestation = false,
onValidationChange = undefined,
}: EditAdvancedSettingsProps) {
+ // Identity assertions and attestation validate independently; each is tracked separately so one
+ // resolving doesn't clobber the other's still-invalid state when both report to the single
+ // upward onValidationChange prop.
+ const [identityAssertionsInvalid, setIdentityAssertionsInvalid] = useState(false);
+ const [attestationInvalid, setAttestationInvalid] = useState(false);
+
+ useEffect(() => {
+ onValidationChange?.(identityAssertionsInvalid || attestationInvalid);
+ }, [identityAssertionsInvalid, attestationInvalid, onValidationChange]);
+
const handleOAuth2ConfigChange = (updates: Partial) => {
const currentInboundAuth: InboundAuthConfig[] = editedApp.inboundAuthConfig ?? application.inboundAuthConfig ?? [];
const updatedInboundAuth = currentInboundAuth.map((auth) =>
@@ -147,7 +158,7 @@ export default function EditAdvancedSettings({
oauth2Config={oauth2Config}
onTokenConfigChange={handleTokenConfigChange}
disabled={application.isReadOnly}
- onValidationChange={onValidationChange}
+ onValidationChange={setIdentityAssertionsInvalid}
/>
)}
)}
diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.roundtrip.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.roundtrip.test.tsx
index 3cd3a57ad4..7d234106f7 100644
--- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.roundtrip.test.tsx
+++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.roundtrip.test.tsx
@@ -28,17 +28,23 @@ vi.mock('react-i18next', () => ({
}));
// Feeds back whatever the section emits as its next prop, mimicking the edit page's
-// config round-trip. Guards against the field becoming un-typeable if the round-trip stalls.
+// config round-trip. Guards against a field becoming un-typeable if the round-trip stalls.
function Harness() {
const [attestation, setAttestation] = useState(undefined);
return ;
}
+async function selectPlatform(user: ReturnType, optionKey: string) {
+ await user.click(screen.getByRole('combobox'));
+ await user.click(await screen.findByRole('option', {name: optionKey}));
+}
+
describe('AttestationSection round-trip', () => {
- it('lets the user type the package name and reflects it back', async () => {
+ it('lets the user select Android and type the package name, reflecting it back', async () => {
const user = userEvent.setup();
render();
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.android');
const input = screen.getByLabelText('applications:edit.advanced.attestation.labels.packageName');
await user.type(input, 'com.example.app');
@@ -49,9 +55,24 @@ describe('AttestationSection round-trip', () => {
const user = userEvent.setup();
render();
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.android');
const creds = screen.getByLabelText('applications:edit.advanced.attestation.labels.serviceAccountCredentials');
await user.type(creds, 'abc123');
expect(creds).toHaveValue('abc123');
});
+
+ it('lets the user select iOS and type the team and bundle ids, reflecting them back', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.apple');
+ const teamId = screen.getByLabelText('applications:edit.advanced.attestation.labels.teamId');
+ const bundleId = screen.getByLabelText('applications:edit.advanced.attestation.labels.bundleId');
+ await user.type(teamId, 'ABCDE12345');
+ await user.type(bundleId, 'com.example.myapp');
+
+ expect(teamId).toHaveValue('ABCDE12345');
+ expect(bundleId).toHaveValue('com.example.myapp');
+ });
});
diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx
index 350cb7543b..205e6e42fb 100644
--- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx
+++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx
@@ -28,6 +28,12 @@ vi.mock('react-i18next', () => ({
}),
}));
+// Selects a platform from the attestation platform Autocomplete.
+async function selectPlatform(user: ReturnType, optionKey: string) {
+ await user.click(screen.getByRole('combobox'));
+ await user.click(await screen.findByRole('option', {name: optionKey}));
+}
+
describe('AttestationSection', () => {
const mockOnAttestationChange = vi.fn();
@@ -36,16 +42,31 @@ describe('AttestationSection', () => {
});
describe('Rendering', () => {
- it('should render the attestation section', () => {
+ it('should render the attestation section with the platform selector', () => {
render();
expect(screen.getByText('applications:edit.advanced.labels.attestation')).toBeInTheDocument();
expect(screen.getByText('applications:edit.advanced.attestation.intro')).toBeInTheDocument();
+ expect(screen.getByText('applications:edit.advanced.attestation.labels.platform')).toBeInTheDocument();
});
- it('should render the package name and credentials fields', () => {
+ it('should not render platform fields when no platform is configured', () => {
render();
+ expect(
+ screen.queryByLabelText('applications:edit.advanced.attestation.labels.packageName'),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('applications:edit.advanced.attestation.labels.teamId')).not.toBeInTheDocument();
+ });
+
+ it('should render the Android fields when an android config is present', () => {
+ render(
+ ,
+ );
+
expect(screen.getByLabelText('applications:edit.advanced.attestation.labels.packageName')).toBeInTheDocument();
expect(
screen.getByLabelText('applications:edit.advanced.attestation.labels.serviceAccountCredentials'),
@@ -76,21 +97,93 @@ describe('AttestationSection', () => {
expect(screen.queryByDisplayValue('secret-json')).not.toBeInTheDocument();
});
+
+ it('should render the Apple fields with values when an apple config is present', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByDisplayValue('ABCDE12345')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('com.example.myapp')).toBeInTheDocument();
+ });
});
describe('Editing', () => {
- it('should emit an attestation config when the package name is set', async () => {
+ it('should emit an android config after selecting Android and setting the package name', async () => {
const user = userEvent.setup({delay: null});
render();
- // The field is controlled by (unchanging) props in this test, so type a single character
- // and assert the emitted config carries it.
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.android');
const input = screen.getByLabelText('applications:edit.advanced.attestation.labels.packageName');
await user.type(input, 'x');
expect(mockOnAttestationChange).toHaveBeenLastCalledWith({android: {packageName: 'x'}});
});
+ it('should not emit an apple config while only the team id is set', async () => {
+ const user = userEvent.setup({delay: null});
+ render();
+
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.apple');
+ const input = screen.getByLabelText('applications:edit.advanced.attestation.labels.teamId');
+ await user.type(input, 'A');
+
+ // Selecting the platform emits the (empty, i.e. null) config once; typing into a lone,
+ // incomplete field must not emit again with a partial apple config the backend can't verify.
+ expect(mockOnAttestationChange).toHaveBeenCalledTimes(1);
+ expect(mockOnAttestationChange).toHaveBeenLastCalledWith(null);
+ });
+
+ it('should emit a complete apple config once both team id and bundle id are set', async () => {
+ const user = userEvent.setup({delay: null});
+ render();
+
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.apple');
+ await user.type(screen.getByLabelText('applications:edit.advanced.attestation.labels.teamId'), 'ABCDE12345');
+ await user.type(
+ screen.getByLabelText('applications:edit.advanced.attestation.labels.bundleId'),
+ 'com.example.myapp',
+ );
+
+ expect(mockOnAttestationChange).toHaveBeenLastCalledWith({
+ apple: {teamId: 'ABCDE12345', bundleId: 'com.example.myapp'},
+ });
+ });
+
+ it('should show a validation hint on the empty field while the apple config is incomplete', async () => {
+ const user = userEvent.setup({delay: null});
+ render(
+ ,
+ );
+
+ // Clearing bundleId leaves teamId alone (incomplete) — the last valid (complete) config is
+ // never overwritten with the partial state, and a validation hint appears on the empty field.
+ await user.clear(screen.getByLabelText('applications:edit.advanced.attestation.labels.bundleId'));
+
+ expect(mockOnAttestationChange).not.toHaveBeenCalled();
+ expect(screen.getByText('applications:edit.advanced.attestation.error.appleIncomplete')).toBeInTheDocument();
+ });
+
+ it('should emit null when the platform is set to None', async () => {
+ const user = userEvent.setup({delay: null});
+ render(
+ ,
+ );
+
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.none');
+
+ expect(mockOnAttestationChange).toHaveBeenLastCalledWith(null);
+ });
+
it('should emit null when the only configured value is cleared', async () => {
const user = userEvent.setup({delay: null});
render(
@@ -108,7 +201,12 @@ describe('AttestationSection', () => {
it('should add a digest row when Add Digest is clicked', async () => {
const user = userEvent.setup({delay: null});
- render();
+ render(
+ ,
+ );
await user.click(screen.getByText('applications:edit.advanced.attestation.addDigest'));
@@ -134,4 +232,47 @@ describe('AttestationSection', () => {
expect(lastArg?.android?.serviceAccountCredentials).toBe('{"type":"service_account"}');
});
});
+
+ describe('Validation', () => {
+ it('reports a validation error while the apple config is incomplete, resolving once both fields are set', async () => {
+ const user = userEvent.setup({delay: null});
+ const mockOnValidationChange = vi.fn();
+ render(
+ ,
+ );
+
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.apple');
+ await user.type(screen.getByLabelText('applications:edit.advanced.attestation.labels.teamId'), 'ABCDE12345');
+
+ expect(mockOnValidationChange).toHaveBeenLastCalledWith(true);
+
+ await user.type(
+ screen.getByLabelText('applications:edit.advanced.attestation.labels.bundleId'),
+ 'com.example.myapp',
+ );
+
+ expect(mockOnValidationChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it('resolves the validation error when the section is cleared back to no platform', async () => {
+ const user = userEvent.setup({delay: null});
+ const mockOnValidationChange = vi.fn();
+ render(
+ ,
+ );
+
+ await user.clear(screen.getByLabelText('applications:edit.advanced.attestation.labels.bundleId'));
+ expect(mockOnValidationChange).toHaveBeenLastCalledWith(true);
+
+ await selectPlatform(user, 'applications:edit.advanced.attestation.platform.none');
+ expect(mockOnValidationChange).toHaveBeenLastCalledWith(false);
+ });
+ });
});
diff --git a/frontend/apps/console/src/features/applications/models/oauth.ts b/frontend/apps/console/src/features/applications/models/oauth.ts
index fa5ab49ef3..4efbbb05c9 100644
--- a/frontend/apps/console/src/features/applications/models/oauth.ts
+++ b/frontend/apps/console/src/features/applications/models/oauth.ts
@@ -494,14 +494,25 @@ export interface OAuth2Config {
}
/**
- * Platform attestation configuration for an application.
+ * Platform attestation configuration for an application. An application configures exactly one
+ * platform: the `android` and `apple` variants are mutually exclusive, so `{}` and a config with
+ * both set are both compile-time errors.
*/
-export interface AttestationConfig {
- /**
- * Google Play Integrity attestation configuration for Android clients.
- */
- android?: AndroidAttestationConfig;
-}
+export type AttestationConfig =
+ | {
+ /**
+ * Google Play Integrity attestation configuration for Android clients.
+ */
+ android: AndroidAttestationConfig;
+ apple?: undefined;
+ }
+ | {
+ android?: undefined;
+ /**
+ * Apple App Attest attestation configuration for iOS clients.
+ */
+ apple: AppleAttestationConfig;
+ };
/**
* Google Play Integrity attestation settings for an Android application.
@@ -525,6 +536,23 @@ export interface AndroidAttestationConfig {
serviceAccountCredentials?: string;
}
+/**
+ * Apple App Attest attestation settings for an iOS application.
+ */
+export interface AppleAttestationConfig {
+ /**
+ * Apple Developer Team ID. Required together with bundleId: the backend needs both to verify
+ * the attested App ID.
+ */
+ teamId: string;
+
+ /**
+ * iOS application bundle identifier that must match the attested app. Required together with
+ * teamId.
+ */
+ bundleId: string;
+}
+
/**
* User Info Configuration
*
diff --git a/frontend/apps/console/src/features/applications/pages/ApplicationEditPage.tsx b/frontend/apps/console/src/features/applications/pages/ApplicationEditPage.tsx
index a096f753e1..fac77ef9ad 100644
--- a/frontend/apps/console/src/features/applications/pages/ApplicationEditPage.tsx
+++ b/frontend/apps/console/src/features/applications/pages/ApplicationEditPage.tsx
@@ -100,6 +100,7 @@ export default function ApplicationEditPage() {
const [tempDescription, setTempDescription] = useState('');
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const [mcpAccessInvalid, setMcpAccessInvalid] = useState(false);
+ const [advancedSettingsInvalid, setAdvancedSettingsInvalid] = useState(false);
const handleBack = async () => {
await navigate('/applications');
@@ -277,7 +278,7 @@ export default function ApplicationEditPage() {
oauth2Constraints={isMcpM2mOnly ? undefined : oauth2Constraints}
onFieldChange={handleFieldChange}
allowedGrantTypes={[...TemplateConstants.MCP_CLIENT_ALLOWED_GRANT_TYPES]}
- onValidationChange={setHasValidationErrors}
+ onValidationChange={setAdvancedSettingsInvalid}
/>
),
},
@@ -560,7 +561,7 @@ export default function ApplicationEditPage() {
oauth2Constraints={oauth2Constraints}
onFieldChange={handleFieldChange}
showAttestation={supportsAttestation}
- onValidationChange={setHasValidationErrors}
+ onValidationChange={setAdvancedSettingsInvalid}
/>
>
@@ -575,7 +576,9 @@ export default function ApplicationEditPage() {
saveLabel={t('applications:edit.page.save')}
savingLabel={t('applications:edit.page.saving')}
isSaving={updateApplication.isPending}
- saveDisabled={hasValidationErrors || mcpAccessInvalid || application.isReadOnly === true}
+ saveDisabled={
+ hasValidationErrors || mcpAccessInvalid || advancedSettingsInvalid || application.isReadOnly === true
+ }
onReset={() => setEditedApp({})}
onSave={() => {
handleSave().catch(() => null);
diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts
index c36ec41fd8..4879c6ad97 100644
--- a/frontend/packages/i18n/src/locales/en-US.ts
+++ b/frontend/packages/i18n/src/locales/en-US.ts
@@ -2468,9 +2468,13 @@ const translations = {
'edit.advanced.certificate.type.none': 'None',
'edit.advanced.certificate.type.jwks': 'JWKS (Inline JSON Web Key Set)',
'edit.advanced.certificate.type.jwksUri': 'JWKS URI (URL to JWKS endpoint)',
- 'edit.advanced.labels.attestation': 'Play Integrity Attestation (Android)',
+ 'edit.advanced.labels.attestation': 'Platform Attestation',
'edit.advanced.attestation.intro':
- 'Verify the binary identity of an Android mobile client with Google Play Integrity when it initiates a flow directly.',
+ 'Verify the binary identity of a mobile client when it initiates a flow directly. Choose the platform the application is built for.',
+ 'edit.advanced.attestation.labels.platform': 'Platform',
+ 'edit.advanced.attestation.platform.none': 'None',
+ 'edit.advanced.attestation.platform.android': 'Android (Play Integrity)',
+ 'edit.advanced.attestation.platform.apple': 'iOS (App Attest)',
'edit.advanced.attestation.labels.packageName': 'Package Name',
'edit.advanced.attestation.labels.certificateSha256Digests': 'Signing Certificate SHA-256 Digests',
'edit.advanced.attestation.labels.serviceAccountCredentials': 'Service Account Credentials',
@@ -2484,6 +2488,13 @@ const translations = {
'edit.advanced.attestation.hint.serviceAccountCredentials':
'Write-only. Used to call the Play Integrity API. Leave blank to keep the existing credentials.',
'edit.advanced.attestation.addDigest': 'Add Digest',
+ 'edit.advanced.attestation.labels.teamId': 'Team ID',
+ 'edit.advanced.attestation.labels.bundleId': 'Bundle ID',
+ 'edit.advanced.attestation.placeholder.teamId': 'ABCDE12345',
+ 'edit.advanced.attestation.placeholder.bundleId': 'com.example.myapp',
+ 'edit.advanced.attestation.hint.teamId': 'The Apple Developer Team ID.',
+ 'edit.advanced.attestation.hint.bundleId': 'The iOS bundle identifier that must match the attested app.',
+ 'edit.advanced.attestation.error.appleIncomplete': 'Both Team ID and Bundle ID are required together.',
/* -------------------- Edit page -------------------- */
// Common
diff --git a/tests/integration/flow/authentication/apple_attestation_flow_test.go b/tests/integration/flow/authentication/apple_attestation_flow_test.go
new file mode 100644
index 0000000000..404b347fb5
--- /dev/null
+++ b/tests/integration/flow/authentication/apple_attestation_flow_test.go
@@ -0,0 +1,258 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
+ *
+ * WSO2 LLC. licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package authentication
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/thunder-id/thunderid/tests/integration/flow/common"
+ "github.com/thunder-id/thunderid/tests/integration/testutils"
+)
+
+// This suite verifies the platform-attestation guard for POST /flow/execute for an iOS (public,
+// authorization_code) application that configures Apple App Attest. Attestation takes precedence
+// over the redirect classification, so the app may initiate a flow directly only by presenting a
+// valid Attestation-Token header:
+// - a missing token is rejected with 401 (FES-1014);
+// - a malformed token is rejected with 401 (FES-1015). Unlike Play Integrity, App Attest is
+// verified entirely offline (no outbound API call), so a token that cannot be decoded is a
+// definitive rejection rather than a server-side condition — it never surfaces as a 500.
+//
+// A successful verification requires a genuine App Attest attestation object produced by a real
+// Apple device, so that path is covered by unit tests and manual/E2E verification rather than here.
+
+var appleAttestationGuardOU = testutils.OrganizationUnit{
+ Handle: "apple_attestation_guard_test_ou",
+ Name: "Apple Attestation Guard Test OU",
+ Description: "Organization unit for Apple attestation guard flow testing",
+ Parent: nil,
+}
+
+var appleAttestationGuardUserType = testutils.UserType{
+ Name: "apple_attestation_guard_person",
+ Schema: map[string]interface{}{
+ "username": map[string]interface{}{"type": "string"},
+ "password": map[string]interface{}{"type": "string", "credential": true},
+ "email": map[string]interface{}{"type": "string"},
+ },
+}
+
+var appleAttestationGuardFlow = testutils.Flow{
+ Name: "Apple Attestation Guard Auth Flow",
+ FlowType: "AUTHENTICATION",
+ Handle: "auth_flow_apple_attestation_guard",
+ Nodes: []map[string]interface{}{
+ {
+ "id": "start",
+ "type": "START",
+ "onSuccess": "prompt_credentials",
+ },
+ {
+ "id": "prompt_credentials",
+ "type": "PROMPT",
+ "prompts": []map[string]interface{}{
+ {
+ "inputs": []map[string]interface{}{
+ {"ref": "input_001", "identifier": "username", "type": "TEXT_INPUT", "required": true},
+ {"ref": "input_002", "identifier": "password", "type": "PASSWORD_INPUT", "required": true},
+ },
+ "action": map[string]interface{}{"ref": "action_001", "nextNode": "credentials_auth"},
+ },
+ },
+ },
+ {
+ "id": "credentials_auth",
+ "type": "TASK_EXECUTION",
+ "executor": map[string]interface{}{
+ "name": "CredentialsAuthExecutor",
+ "inputs": []map[string]interface{}{
+ {"ref": "input_001", "identifier": "username", "type": "TEXT_INPUT", "required": true},
+ {"ref": "input_002", "identifier": "password", "type": "PASSWORD_INPUT", "required": true},
+ },
+ },
+ "onSuccess": "auth_assert",
+ "onIncomplete": "prompt_credentials",
+ },
+ {
+ "id": "auth_assert",
+ "type": "TASK_EXECUTION",
+ "executor": map[string]interface{}{"name": "AuthAssertExecutor"},
+ "onSuccess": "end",
+ },
+ {
+ "id": "end",
+ "type": "END",
+ },
+ },
+}
+
+// appleAttestationMobileApp is a public, redirect-based (authorization_code) iOS application that
+// configures Apple App Attest attestation. Attestation takes precedence over the redirect
+// classification, so the app may initiate a flow directly — but only with a valid attestation token.
+var appleAttestationMobileApp = testutils.Application{
+ Name: "App Attest iOS App",
+ Description: "iOS application for Apple attestation guard testing",
+ IsRegistrationFlowEnabled: false,
+ ClientID: "apple_attestation_mobile_client",
+ RedirectURIs: []string{"myiosapp://callback"},
+ AllowedUserTypes: []string{"apple_attestation_guard_person"},
+ // Attestation is a client-level setting, configured at the top level of the application
+ // independent of the OAuth2 protocol config. Apple's config carries only non-secret identifiers.
+ Attestation: map[string]interface{}{
+ "apple": map[string]interface{}{
+ "teamId": "ABCDE12345",
+ "bundleId": "com.example.myiosapp",
+ },
+ },
+ InboundAuthConfig: []map[string]interface{}{
+ {
+ "type": "oauth2",
+ "config": map[string]interface{}{
+ "clientId": "apple_attestation_mobile_client",
+ "redirectUris": []string{"myiosapp://callback"},
+ "grantTypes": []string{"authorization_code"},
+ "responseTypes": []string{"code"},
+ "tokenEndpointAuthMethod": "none",
+ "publicClient": true,
+ "pkceRequired": true,
+ },
+ },
+ },
+}
+
+var (
+ appleAttestationGuardOUID string
+ appleAttestationGuardUserTypeID string
+ appleAttestationGuardFlowID string
+ appleAttestationMobileAppID string
+)
+
+// AppleAttestationFlowTestSuite verifies the Apple App Attest guard on direct flow initiation.
+type AppleAttestationFlowTestSuite struct {
+ suite.Suite
+ config *common.TestSuiteConfig
+}
+
+func TestAppleAttestationFlowTestSuite(t *testing.T) {
+ suite.Run(t, new(AppleAttestationFlowTestSuite))
+}
+
+func (ts *AppleAttestationFlowTestSuite) SetupSuite() {
+ ts.config = &common.TestSuiteConfig{}
+
+ ouID, err := testutils.CreateOrganizationUnit(appleAttestationGuardOU)
+ ts.Require().NoError(err, "failed to create OU")
+ appleAttestationGuardOUID = ouID
+
+ appleAttestationGuardUserType.OUID = appleAttestationGuardOUID
+ schemaID, err := testutils.CreateUserType(appleAttestationGuardUserType)
+ ts.Require().NoError(err, "failed to create user type")
+ appleAttestationGuardUserTypeID = schemaID
+
+ flowID, err := testutils.CreateFlow(appleAttestationGuardFlow)
+ ts.Require().NoError(err, "failed to create auth flow")
+ appleAttestationGuardFlowID = flowID
+ ts.config.CreatedFlowIDs = append(ts.config.CreatedFlowIDs, flowID)
+
+ appleAttestationMobileApp.AuthFlowID = flowID
+ appleAttestationMobileApp.OUID = appleAttestationGuardOUID
+ appID, err := testutils.CreateApplication(appleAttestationMobileApp)
+ ts.Require().NoError(err, "failed to create iOS application")
+ appleAttestationMobileAppID = appID
+
+ // A public / redirect-based mobile app is never issued a Flow Secret.
+ ts.Require().Empty(testutils.GetFlowSecret(appleAttestationMobileAppID),
+ "iOS app should not be issued a Flow Secret")
+}
+
+func (ts *AppleAttestationFlowTestSuite) TearDownSuite() {
+ if appleAttestationMobileAppID != "" {
+ if err := testutils.DeleteApplication(appleAttestationMobileAppID); err != nil {
+ ts.T().Logf("failed to delete iOS application: %v", err)
+ }
+ }
+ for _, id := range ts.config.CreatedFlowIDs {
+ if err := testutils.DeleteFlow(id); err != nil {
+ ts.T().Logf("failed to delete flow %s: %v", id, err)
+ }
+ }
+ if appleAttestationGuardUserTypeID != "" {
+ if err := testutils.DeleteUserType(appleAttestationGuardUserTypeID); err != nil {
+ ts.T().Logf("failed to delete user type: %v", err)
+ }
+ }
+ if appleAttestationGuardOUID != "" {
+ if err := testutils.DeleteOrganizationUnit(appleAttestationGuardOUID); err != nil {
+ ts.T().Logf("failed to delete OU: %v", err)
+ }
+ }
+}
+
+// executeNewFlowWithAttestation posts a new-flow INIT request, optionally presenting an attestation
+// token via the Attestation-Token header, and returns the HTTP status code and parsed error body.
+func (ts *AppleAttestationFlowTestSuite) executeNewFlowWithAttestation(
+ body map[string]interface{}, attestationToken string) (int, *common.ErrorResponse) {
+ reqBody, err := json.Marshal(body)
+ ts.Require().NoError(err, "failed to marshal flow request")
+
+ req, err := http.NewRequest("POST", testutils.TestServerURL+"/flow/execute", bytes.NewReader(reqBody))
+ ts.Require().NoError(err, "failed to create flow request")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ if attestationToken != "" {
+ req.Header.Set("Attestation-Token", attestationToken)
+ }
+
+ resp, err := testutils.GetHTTPClient().Do(req)
+ ts.Require().NoError(err, "failed to send flow request")
+ defer resp.Body.Close()
+
+ var errResp common.ErrorResponse
+ ts.Require().NoError(json.NewDecoder(resp.Body).Decode(&errResp),
+ "flow error response should be valid JSON")
+ return resp.StatusCode, &errResp
+}
+
+// An iOS app that omits the attestation token is rejected with 401 (FES-1014).
+func (ts *AppleAttestationFlowTestSuite) TestIOSApp_MissingAttestationToken_Rejected() {
+ status, errResp := ts.executeNewFlowWithAttestation(map[string]interface{}{
+ "applicationId": appleAttestationMobileAppID,
+ "flowType": "AUTHENTICATION",
+ }, "")
+
+ ts.Require().Equal(http.StatusUnauthorized, status)
+ ts.Require().Equal("FES-1014", errResp.Code)
+}
+
+// A malformed attestation object cannot be decoded. App Attest is verified offline, so this is a
+// definitive token rejection (401, FES-1015), never a server error.
+func (ts *AppleAttestationFlowTestSuite) TestIOSApp_MalformedAttestationToken_Rejected() {
+ status, errResp := ts.executeNewFlowWithAttestation(map[string]interface{}{
+ "applicationId": appleAttestationMobileAppID,
+ "flowType": "AUTHENTICATION",
+ }, "not-a-valid-attestation-object")
+
+ ts.Require().Equal(http.StatusUnauthorized, status)
+ ts.Require().Equal("FES-1015", errResp.Code)
+}