From 21b7c85d5c11ae768ee146e1dad7e23463eaa776 Mon Sep 17 00:00:00 2001 From: Malith-19 Date: Mon, 13 Jul 2026 14:07:12 +0530 Subject: [PATCH] Add Play Integrity attestation for mobile clients --- api/application.yaml | 38 +++ api/flow-execution.yaml | 25 ++ backend/.mockery.private.yml | 8 + backend/.mockery.public.yml | 6 + backend/cmd/server/servicemanager.go | 9 +- backend/go.mod | 23 +- backend/go.sum | 44 ++- backend/internal/application/handler.go | 5 + backend/internal/application/init.go | 4 +- backend/internal/application/init_test.go | 4 + backend/internal/application/service.go | 66 +++++ backend/internal/application/service_test.go | 122 ++++++++ backend/internal/attestation/decoder.go | 68 +++++ .../internal/attestation/error_constants.go | 36 +++ backend/internal/attestation/init.go | 29 ++ .../integrityTokenDecoder_mock_test.go | 119 ++++++++ .../internal/attestation/play_integrity.go | 165 +++++++++++ .../attestation/play_integrity_test.go | 200 +++++++++++++ .../FlowExecServiceInterface_mock_test.go | 30 +- backend/internal/flow/flowexec/constants.go | 5 + .../internal/flow/flowexec/error_constants.go | 30 ++ backend/internal/flow/flowexec/handler.go | 7 +- .../internal/flow/flowexec/handler_test.go | 32 ++- backend/internal/flow/flowexec/init.go | 4 +- backend/internal/flow/flowexec/interface.go | 2 +- backend/internal/flow/flowexec/service.go | 116 +++++--- .../internal/flow/flowexec/service_test.go | 157 +++++++++-- backend/internal/inboundclient/store.go | 3 + .../system/constants/server_constants.go | 4 + backend/internal/system/i18n/core/defaults.go | 4 + backend/pkg/thunderidengine/engine.go | 5 +- .../thunderidengine/providers/interface.go | 12 + .../pkg/thunderidengine/providers/model.go | 37 ++- .../AttestationProvider_mock.go | 114 ++++++++ .../FlowExecServiceInterface_mock.go | 30 +- .../flowexecmock/attestationVerifier_mock.go | 102 +++++++ .../applications/application-settings.mdx | 14 + .../authentication/integration-models.mdx | 21 ++ .../advanced-settings/AttestationSection.tsx | 235 ++++++++++++++++ .../EditAdvancedSettings.tsx | 27 +- .../AttestationSection.roundtrip.test.tsx | 57 ++++ .../__tests__/AttestationSection.test.tsx | 137 +++++++++ .../__tests__/EditAdvancedSettings.test.tsx | 27 ++ .../platform-based/mobile.json | 3 + .../platform-based/wallet.json | 3 + .../models/application-templates.ts | 10 + .../applications/models/application.ts | 9 + .../src/features/applications/models/oauth.ts | 32 +++ .../pages/ApplicationEditPage.tsx | 8 + .../utils/getTemplateCapabilities.ts | 51 ++++ frontend/packages/i18n/src/locales/en-US.ts | 16 ++ .../application/application_api_test.go | 56 ++++ tests/integration/application/model.go | 33 +++ .../authentication/attestation_flow_test.go | 264 ++++++++++++++++++ tests/integration/testutils/api_utils.go | 5 + tests/integration/testutils/models.go | 3 + 56 files changed, 2553 insertions(+), 123 deletions(-) create mode 100644 backend/internal/attestation/decoder.go create mode 100644 backend/internal/attestation/error_constants.go create mode 100644 backend/internal/attestation/init.go create mode 100644 backend/internal/attestation/integrityTokenDecoder_mock_test.go create mode 100644 backend/internal/attestation/play_integrity.go create mode 100644 backend/internal/attestation/play_integrity_test.go create mode 100644 backend/tests/mocks/attestationprovidermock/AttestationProvider_mock.go create mode 100644 backend/tests/mocks/flow/flowexecmock/attestationVerifier_mock.go create mode 100644 frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx create mode 100644 frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.roundtrip.test.tsx create mode 100644 frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx create mode 100644 frontend/apps/console/src/features/applications/utils/getTemplateCapabilities.ts create mode 100644 tests/integration/flow/authentication/attestation_flow_test.go diff --git a/api/application.yaml b/api/application.yaml index 27004dcfc4..433c2204a3 100644 --- a/api/application.yaml +++ b/api/application.yaml @@ -812,6 +812,8 @@ components: example: "https://myapp.example.com/logo.png" assertion: $ref: '#/components/schemas/AssertionConfig' + attestation: + $ref: '#/components/schemas/Attestation' inboundAuthConfig: type: array items: @@ -940,6 +942,8 @@ components: example: "https://myapp.example.com/logo.png" assertion: $ref: '#/components/schemas/AssertionConfig' + attestation: + $ref: '#/components/schemas/Attestation' inboundAuthConfig: type: array items: @@ -1052,6 +1056,8 @@ components: example: "https://myapp.example.com/logo.png" assertion: $ref: '#/components/schemas/AssertionConfig' + attestation: + $ref: '#/components/schemas/Attestation' inboundAuthConfig: type: array items: @@ -1362,6 +1368,38 @@ components: description: The value of the certificate. example: "https://myapp.example.com/.well-known/jwks" + Attestation: + type: object + description: > + Platform attestation configuration used to verify the binary identity of a mobile client + when it initiates a flow directly over HTTP. + properties: + android: + $ref: '#/components/schemas/AndroidAttestation' + + AndroidAttestation: + type: object + description: Google Play Integrity attestation configuration for Android clients. + properties: + packageName: + type: string + description: Android application package name that must match the attested app. + example: "com.example.myapp" + certificateSha256Digests: + type: array + items: + type: string + description: > + Allowed SHA-256 digests of the app signing certificate, in the URL-safe base64 form + reported by the Play Integrity API. The attested app must match one of these. + example: ["9VvjU4Gu7dg1234examplebase64digest"] + serviceAccountCredentials: + type: string + writeOnly: true + description: > + 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. + InboundAuthConfig: type: object properties: diff --git a/api/flow-execution.yaml b/api/flow-execution.yaml index e210bd403a..65b85becc1 100644 --- a/api/flow-execution.yaml +++ b/api/flow-execution.yaml @@ -35,6 +35,16 @@ paths: security: - {} - FlowSecret: [] + parameters: + - in: header + name: Attestation-Token + required: false + schema: + type: string + description: > + Platform attestation token (e.g. a Google Play Integrity token) presented by a mobile + application when initiating a new authentication flow. Required only for applications + configured with attestation; ignored when continuing an existing flow via executionId. requestBody: required: true content: @@ -199,6 +209,21 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + "401": + description: > + Unauthorized: The application must present a valid credential to initiate a new flow — + a Flow Secret for backend applications, or a platform attestation token for mobile + applications — and the presented credential was missing or invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "403": + description: 'Forbidden: Direct flow initiation is not permitted for this application type' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' "500": description: 'Internal Server Error: An unexpected error occurred while processing the request' content: diff --git a/backend/.mockery.private.yml b/backend/.mockery.private.yml index f3e4760ae0..1fe23e7631 100644 --- a/backend/.mockery.private.yml +++ b/backend/.mockery.private.yml @@ -413,3 +413,11 @@ packages: structname: '{{.InterfaceName}}Mock' pkgname: flowexec filename: "{{.InterfaceName}}_mock_test.go" + + github.com/thunder-id/thunderid/internal/attestation: + config: + all: true + dir: internal/attestation + structname: '{{.InterfaceName}}Mock' + pkgname: attestation + filename: "{{.InterfaceName}}_mock_test.go" diff --git a/backend/.mockery.public.yml b/backend/.mockery.public.yml index 9a504dfcc8..89e85bf63e 100644 --- a/backend/.mockery.public.yml +++ b/backend/.mockery.public.yml @@ -589,6 +589,12 @@ packages: structname: ExecutorInterfaceMock pkgname: coremock filename: "ExecutorInterface_mock.go" + AttestationProvider: + config: + dir: tests/mocks/attestationprovidermock + structname: '{{.InterfaceName}}Mock' + pkgname: attestationprovidermock + filename: "{{.InterfaceName}}_mock.go" github.com/thunder-id/thunderid/internal/authnprovider/provider: config: diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index 7a81db01b6..f3de3bd4de 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -29,6 +29,7 @@ import ( "github.com/thunder-id/thunderid/internal/actorprovider" "github.com/thunder-id/thunderid/internal/agent" "github.com/thunder-id/thunderid/internal/application" + "github.com/thunder-id/thunderid/internal/attestation" "github.com/thunder-id/thunderid/internal/attributecache" "github.com/thunder-id/thunderid/internal/authn" authnAssert "github.com/thunder-id/thunderid/internal/authn/assert" @@ -403,7 +404,8 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa // TODO: Remove entityService dependency after finalizing declarative resource loading pattern applicationService, applicationExporter, err := application.Initialize( - mux, mcpServer, entityProvider, entityService, inboundClientService, ouService, i18nService) + mux, mcpServer, entityProvider, entityService, inboundClientService, ouService, i18nService, + runtimeCryptoSvc) if err != nil { logger.Fatal(ctx, "Failed to initialize ApplicationService", log.Error(err)) } @@ -467,9 +469,10 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa serverConfigService, ) + attestationProvider := attestation.Initialize(runtimeCryptoSvc) flowExecService, err := flowexec.Initialize(mux, flowMgtService, actorProvider, - execRegistry, interceptorRegistry, observabilitySvc, runtimeCryptoSvc, graphBuilder, - runtimeStoreProvider, transactioner, flowConfig) + execRegistry, interceptorRegistry, observabilitySvc, runtimeCryptoSvc, attestationProvider, + graphBuilder, runtimeStoreProvider, transactioner, flowConfig) if err != nil { logger.Fatal(ctx, "Failed to initialize flow execution service", log.Error(err)) } diff --git a/backend/go.mod b/backend/go.mod index 3cf11290f1..dda02e0701 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,9 @@ module github.com/thunder-id/thunderid go 1.26 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/go-webauthn/webauthn v0.17.4 github.com/google/jsonschema-go v0.4.3 github.com/lib/pq v1.10.9 @@ -17,19 +19,22 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 golang.org/x/crypto v0.53.0 - golang.org/x/net v0.55.0 + golang.org/x/net v0.56.0 golang.org/x/text v0.38.0 + google.golang.org/api v0.288.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 ) require ( + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.6.4 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 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 @@ -37,12 +42,15 @@ require ( github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -51,14 +59,15 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.81.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index e925989361..bd700af30e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= +cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -10,12 +16,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -43,8 +51,14 @@ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+ github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -66,8 +80,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -92,6 +106,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= @@ -120,8 +136,8 @@ golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= @@ -135,12 +151,16 @@ golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/api v0.288.0 h1:glhO/J88obKP5I269W3hB73dvBKrjU56ZfmNlNXpgTU= +google.golang.org/api v0.288.0/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/application/handler.go b/backend/internal/application/handler.go index 1ae4f25cd5..92bd06f7e1 100644 --- a/backend/internal/application/handler.go +++ b/backend/internal/application/handler.go @@ -81,6 +81,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter Assertion: appRequest.Assertion, AllowedUserTypes: appRequest.AllowedUserTypes, LoginConsent: appRequest.LoginConsent, + Attestation: appRequest.Attestation, }, Template: appRequest.Template, FlowSecret: appRequest.FlowSecret, @@ -116,6 +117,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter Assertion: createdAppDTO.Assertion, AllowedUserTypes: createdAppDTO.AllowedUserTypes, LoginConsent: createdAppDTO.LoginConsent, + Attestation: createdAppDTO.Attestation, }, Template: createdAppDTO.Template, FlowSecret: createdAppDTO.FlowSecret, @@ -194,6 +196,7 @@ func (ah *applicationHandler) HandleApplicationGetRequest(w http.ResponseWriter, Assertion: appDTO.Assertion, AllowedUserTypes: appDTO.AllowedUserTypes, LoginConsent: appDTO.LoginConsent, + Attestation: appDTO.Attestation, }, Template: appDTO.Template, URL: appDTO.URL, @@ -333,6 +336,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, Assertion: appRequest.Assertion, AllowedUserTypes: appRequest.AllowedUserTypes, LoginConsent: appRequest.LoginConsent, + Attestation: appRequest.Attestation, }, Template: appRequest.Template, FlowSecret: appRequest.FlowSecret, @@ -368,6 +372,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, Assertion: updatedAppDTO.Assertion, AllowedUserTypes: updatedAppDTO.AllowedUserTypes, LoginConsent: updatedAppDTO.LoginConsent, + Attestation: updatedAppDTO.Attestation, }, Template: updatedAppDTO.Template, URL: updatedAppDTO.URL, diff --git a/backend/internal/application/init.go b/backend/internal/application/init.go index 3b30545713..0db79aa94b 100644 --- a/backend/internal/application/init.go +++ b/backend/internal/application/init.go @@ -32,6 +32,7 @@ import ( serverconst "github.com/thunder-id/thunderid/internal/system/constants" declarativeresource "github.com/thunder-id/thunderid/internal/system/declarative_resource" i18nmgt "github.com/thunder-id/thunderid/internal/system/i18n/mgt" + kmprovider "github.com/thunder-id/thunderid/internal/system/kmprovider/common" "github.com/thunder-id/thunderid/internal/system/middleware" ) @@ -44,9 +45,10 @@ func Initialize( inboundClient inboundclient.InboundClientServiceInterface, ouService oupkg.OrganizationUnitServiceInterface, i18nService i18nmgt.I18nServiceInterface, + cryptoSvc kmprovider.RuntimeCryptoProvider, ) (ApplicationServiceInterface, declarativeresource.ResourceExporter, error) { appService := newApplicationService( - inboundClient, entityProvider, ouService, i18nService, + inboundClient, entityProvider, ouService, i18nService, cryptoSvc, ) if err := entityService.LoadIndexedAttributes(getAppIndexedAttributes()); err != nil { diff --git a/backend/internal/application/init_test.go b/backend/internal/application/init_test.go index 5d7df84eea..caff3b0697 100644 --- a/backend/internal/application/init_test.go +++ b/backend/internal/application/init_test.go @@ -158,6 +158,7 @@ func (suite *InitTestSuite) TestInitialize_WithDeclarativeResourcesDisabled() { inboundclientmock.NewInboundClientServiceInterfaceMock(suite.T()), nil, // ouService - not needed for this test nil, // i18nService - not needed for this test + nil, // cryptoSvc - not needed for this test ) // Assert @@ -200,6 +201,7 @@ func (suite *InitTestSuite) TestInitialize_WithMCPServer() { inboundclientmock.NewInboundClientServiceInterfaceMock(suite.T()), nil, // ouService - not needed for this test nil, // i18nService - not needed for this test + nil, // cryptoSvc - not needed for this test ) // Assert @@ -590,6 +592,7 @@ func TestInitialize_Standalone(t *testing.T) { inboundclientmock.NewInboundClientServiceInterfaceMock(t), nil, // ouService - not needed for this test nil, // i18nService - not needed for this test + nil, // cryptoSvc - not needed for this test ) // Assert @@ -640,6 +643,7 @@ func TestInitialize_WithDeclarativeResources_Standalone(t *testing.T) { mockInboundClient, nil, // ouService - not needed for this test nil, // i18nService - not needed for this test + nil, // cryptoSvc - not needed for this test ) // Assert diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index 8cbd35b3a0..8397f22e68 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -38,7 +38,9 @@ import ( oupkg "github.com/thunder-id/thunderid/internal/ou" "github.com/thunder-id/thunderid/internal/system/config" serverconst "github.com/thunder-id/thunderid/internal/system/constants" + "github.com/thunder-id/thunderid/internal/system/cryptolib" i18nmgt "github.com/thunder-id/thunderid/internal/system/i18n/mgt" + kmprovider "github.com/thunder-id/thunderid/internal/system/kmprovider/common" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/internal/system/resourcedependency" sysutils "github.com/thunder-id/thunderid/internal/system/utils" @@ -70,6 +72,7 @@ type applicationService struct { entityProvider entityprovider.EntityProviderInterface ouService oupkg.OrganizationUnitServiceInterface i18nService i18nmgt.I18nServiceInterface + cryptoSvc kmprovider.RuntimeCryptoProvider dependencyRegistry resourcedependency.Registry } @@ -79,6 +82,7 @@ func newApplicationService( entityProvider entityprovider.EntityProviderInterface, ouService oupkg.OrganizationUnitServiceInterface, i18nService i18nmgt.I18nServiceInterface, + cryptoSvc kmprovider.RuntimeCryptoProvider, ) ApplicationServiceInterface { return &applicationService{ logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "ApplicationService")), @@ -86,6 +90,7 @@ func newApplicationService( entityProvider: entityProvider, ouService: ouService, i18nService: i18nService, + cryptoSvc: cryptoSvc, } } @@ -121,6 +126,9 @@ func (as *applicationService) CreateApplication(ctx context.Context, app *model. inboundClient := toInboundClient(processedDTO) oauthProfile := toOAuthProfile(processedDTO) + if svcErr := as.resolveAttestationCredentialsForPersist(ctx, appID, &inboundClient); svcErr != nil { + return nil, svcErr + } // Create entity. var clientID string @@ -392,6 +400,9 @@ func (as *applicationService) UpdateApplication(ctx context.Context, appID strin inboundClient := toInboundClient(processedDTO) oauthProfile := toOAuthProfile(processedDTO) + if svcErr := as.resolveAttestationCredentialsForPersist(ctx, appID, &inboundClient); svcErr != nil { + return nil, svcErr + } var newOAuthClientID string if inboundAuthConfig != nil && inboundAuthConfig.OAuthConfig != nil { @@ -764,6 +775,7 @@ func toInboundClient(dto *model.ApplicationProcessedDTO) inboundmodel.InboundCli Assertion: dto.Assertion, LoginConsent: dto.LoginConsent, AllowedUserTypes: dto.AllowedUserTypes, + Attestation: dto.Attestation, } // Pack remaining fields into Properties. @@ -814,6 +826,7 @@ func toProcessedDTO( Assertion: dao.Assertion, LoginConsent: dao.LoginConsent, AllowedUserTypes: dao.AllowedUserTypes, + Attestation: dao.Attestation.WithoutCredentials(), }, } @@ -1643,6 +1656,56 @@ func resolveClientSecret( return nil } +// resolveAttestationCredentialsForPersist prepares the write-only Play Integrity service account +// credentials on the inbound client for persistence: newly supplied credentials are encrypted so +// they are never stored in plaintext, while an omitted value on an update falls back to the +// previously stored (encrypted) credentials. The client's Attestation is replaced with a fresh copy +// so the caller's input is not mutated. +func (as *applicationService) resolveAttestationCredentialsForPersist( + ctx context.Context, appID string, inboundClient *inboundmodel.InboundClient, +) *tidcommon.ServiceError { + if inboundClient == nil || inboundClient.Attestation == nil || + inboundClient.Attestation.Android == nil { + return nil + } + + android := *inboundClient.Attestation.Android + android.CertificateSha256Digests = append([]string(nil), + inboundClient.Attestation.Android.CertificateSha256Digests...) + + if android.ServiceAccountCredentials != "" { + params := cryptolib.AlgorithmParams{Algorithm: cryptolib.AlgorithmAESGCM} + ciphertext, _, err := as.cryptoSvc.Encrypt(ctx, nil, params, + []byte(android.ServiceAccountCredentials)) + if err != nil { + as.logger.Error(ctx, "Failed to encrypt attestation credentials", + log.String("appID", appID), log.Error(err)) + return &tidcommon.InternalServerError + } + android.ServiceAccountCredentials = string(ciphertext) + } else { + // No new credentials supplied: preserve the existing stored (encrypted) value. Distinguish a + // missing record (nothing to preserve, e.g. on create) from a genuine lookup failure — the + // latter must not silently overwrite stored credentials with an empty value. + existing, err := as.inboundClientService.GetInboundClientByEntityID(ctx, appID) + switch { + case err == nil: + if existing != nil && existing.Attestation != nil && existing.Attestation.Android != nil { + android.ServiceAccountCredentials = existing.Attestation.Android.ServiceAccountCredentials + } + case errors.Is(err, inboundclient.ErrInboundClientNotFound): + // No existing record; there is no stored credential to preserve. + default: + as.logger.Error(ctx, "Failed to load existing attestation credentials for preservation", + log.String("appID", appID), log.Error(err)) + return &tidcommon.InternalServerError + } + } + + inboundClient.Attestation = &providers.AttestationConfig{Android: &android} + return nil +} + // enrichApplicationWithCertificate retrieves and adds OAuth certificates to the application. func (as *applicationService) enrichApplicationWithCertificate( ctx context.Context, application *providers.Application, @@ -1683,6 +1746,7 @@ func buildApplicationResponse(dto *model.ApplicationProcessedDTO) *providers.App Assertion: dto.Assertion, AllowedUserTypes: dto.AllowedUserTypes, LoginConsent: dto.LoginConsent, + Attestation: dto.Attestation, }, Template: dto.Template, URL: dto.URL, @@ -1786,6 +1850,7 @@ func buildBaseApplicationProcessedDTO(appID string, app *model.ApplicationDTO, Assertion: assertion, AllowedUserTypes: app.AllowedUserTypes, LoginConsent: app.LoginConsent, + Attestation: app.Attestation, }, Template: app.Template, URL: app.URL, @@ -1866,6 +1931,7 @@ func buildReturnApplicationDTO( Assertion: assertion, AllowedUserTypes: app.AllowedUserTypes, LoginConsent: app.LoginConsent, + Attestation: app.Attestation.WithoutCredentials(), }, Template: app.Template, URL: app.URL, diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go index 072532085e..f74ae91b3a 100644 --- a/backend/internal/application/service_test.go +++ b/backend/internal/application/service_test.go @@ -43,6 +43,7 @@ import ( "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/internal/system/resourcedependency" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/thunder-id/thunderid/tests/mocks/crypto/cryptomock" "github.com/thunder-id/thunderid/tests/mocks/entityprovidermock" "github.com/thunder-id/thunderid/tests/mocks/i18n/mgtmock" "github.com/thunder-id/thunderid/tests/mocks/inboundclientmock" @@ -1604,6 +1605,127 @@ func (suite *ServiceTestSuite) TestUpdateApplication_StoreErrorWithRollback() { assert.Equal(suite.T(), &tidcommon.InternalServerError, svcErr) } +// A mobile application configured with Play Integrity attestation must have its service account +// credentials encrypted before persistence and stripped from the response. +func (suite *ServiceTestSuite) TestCreateApplication_WithAttestation_EncryptsAndStripsCredentials() { + testConfig := &config.Config{ + DeclarativeResources: config.DeclarativeResources{Enabled: false}, + } + config.ResetServerRuntime() + err := config.InitializeServerRuntime("/tmp/test", testConfig) + require.NoError(suite.T(), err) + defer config.ResetServerRuntime() + + service, mockStore := suite.setupTestService() + mockCrypto := cryptomock.NewRuntimeCryptoProviderMock(suite.T()) + service.cryptoSvc = mockCrypto + + const rawCreds = `{"type":"service_account"}` + mockCrypto.EXPECT().Encrypt(mock.Anything, mock.Anything, mock.Anything, []byte(rawCreds)). + Return([]byte("encrypted-creds"), nil, nil) + + var persistedClient *inboundmodel.InboundClient + mockStore.On("CreateInboundClient", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + persistedClient, _ = args.Get(1).(*inboundmodel.InboundClient) + }).Return(nil) + + app := &model.ApplicationDTO{ + Name: "Mobile App", + OUID: testOUID, + InboundAuthProfile: providers.InboundAuthProfile{ + AuthFlowID: "auth-flow-id", + // Attestation is a client-level setting, configured at the top level of the application + // regardless of protocol. + Attestation: &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{ + PackageName: "com.example.app", + CertificateSha256Digests: []string{"AA:BB"}, + ServiceAccountCredentials: rawCreds, + }, + }, + }, + InboundAuthConfig: []providers.InboundAuthConfigWithSecret{ + { + Type: providers.OAuthInboundAuthType, + OAuthConfig: &providers.OAuthConfigWithSecret{ + ClientID: testClientID, + RedirectURIs: []string{"myapp://callback"}, + GrantTypes: []providers.GrantType{providers.GrantTypeAuthorizationCode}, + ResponseTypes: []providers.ResponseType{providers.ResponseTypeCode}, + TokenEndpointAuthMethod: providers.TokenEndpointAuthMethodNone, + PublicClient: true, + PKCERequired: true, + }, + }, + }, + } + + result, svcErr := service.CreateApplication(context.Background(), app) + require.Nil(suite.T(), svcErr) + require.NotNil(suite.T(), result) + + // The persisted inbound client carries the encrypted credentials, never the plaintext. + require.NotNil(suite.T(), persistedClient) + require.NotNil(suite.T(), persistedClient.Attestation) + require.NotNil(suite.T(), persistedClient.Attestation.Android) + assert.Equal(suite.T(), "encrypted-creds", persistedClient.Attestation.Android.ServiceAccountCredentials) + assert.Equal(suite.T(), "com.example.app", persistedClient.Attestation.Android.PackageName) + + // The response echoes package name and digests at the top level but never the credentials. + require.NotNil(suite.T(), result.Attestation) + require.NotNil(suite.T(), result.Attestation.Android) + assert.Equal(suite.T(), "com.example.app", result.Attestation.Android.PackageName) + assert.Empty(suite.T(), result.Attestation.Android.ServiceAccountCredentials) +} + +// When credentials are omitted (as on an update that does not change them), the previously stored +// encrypted value is preserved rather than overwritten with an empty value. +func (suite *ServiceTestSuite) TestResolveAttestationCredentials_PreservesExistingWhenOmitted() { + service, mockStore := suite.setupTestService() + + const appID = "app-1" + mockStore.On("GetInboundClientByEntityID", mock.Anything, appID).Return( + &inboundmodel.InboundClient{ + Attestation: &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{ServiceAccountCredentials: "stored-encrypted"}, + }, + }, nil) + + inboundClient := &inboundmodel.InboundClient{ + Attestation: &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{PackageName: "com.example.app"}, + }, + } + + svcErr := service.resolveAttestationCredentialsForPersist(context.Background(), appID, inboundClient) + require.Nil(suite.T(), svcErr) + assert.Equal(suite.T(), "stored-encrypted", inboundClient.Attestation.Android.ServiceAccountCredentials) + assert.Equal(suite.T(), "com.example.app", inboundClient.Attestation.Android.PackageName) +} + +// A non-"not found" lookup failure while preserving omitted credentials is propagated as an internal +// error, so a transient store failure cannot silently overwrite stored credentials with an empty +// value. +func (suite *ServiceTestSuite) TestResolveAttestationCredentials_LookupErrorPropagates() { + service, mockStore := suite.setupTestService() + + const appID = "app-1" + mockStore.On("GetInboundClientByEntityID", mock.Anything, appID).Return( + (*inboundmodel.InboundClient)(nil), errors.New("database unavailable")) + + inboundClient := &inboundmodel.InboundClient{ + Attestation: &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{PackageName: "com.example.app"}, + }, + } + + svcErr := service.resolveAttestationCredentialsForPersist(context.Background(), appID, inboundClient) + require.NotNil(suite.T(), svcErr) + assert.Equal(suite.T(), tidcommon.InternalServerError.Code, svcErr.Code) +} + func (suite *ServiceTestSuite) TestCreateApplication_ValidateApplicationError() { testConfig := &config.Config{ DeclarativeResources: config.DeclarativeResources{ diff --git a/backend/internal/attestation/decoder.go b/backend/internal/attestation/decoder.go new file mode 100644 index 0000000000..be0e8ece9a --- /dev/null +++ b/backend/internal/attestation/decoder.go @@ -0,0 +1,68 @@ +/* + * 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" + "fmt" + + "cloud.google.com/go/auth/credentials" + "google.golang.org/api/option" + playintegrity "google.golang.org/api/playintegrity/v1" +) + +// integrityTokenDecoder decodes a Play Integrity token into its plaintext payload by calling +// Google's Play Integrity API. It is an internal seam so the API call can be mocked in tests. +type integrityTokenDecoder interface { + Decode(ctx context.Context, credentialsJSON, packageName, token string) ( + *playintegrity.TokenPayloadExternal, error) +} + +// googlePlayIntegrityDecoder decodes tokens by calling the Google Play Integrity API using the +// application's service account credentials. +type googlePlayIntegrityDecoder struct{} + +// newGooglePlayIntegrityDecoder creates a token decoder backed by the Google Play Integrity API. +func newGooglePlayIntegrityDecoder() integrityTokenDecoder { + return &googlePlayIntegrityDecoder{} +} + +// Decode calls the Play Integrity decodeIntegrityToken endpoint for the given package. +func (d *googlePlayIntegrityDecoder) Decode(ctx context.Context, credentialsJSON, packageName, token string) ( + *playintegrity.TokenPayloadExternal, error) { + creds, err := credentials.DetectDefault(&credentials.DetectOptions{ + CredentialsJSON: []byte(credentialsJSON), + Scopes: []string{playintegrity.PlayintegrityScope}, + }) + if err != nil { + return nil, fmt.Errorf("failed to parse play integrity credentials: %w", err) + } + + svc, err := playintegrity.NewService(ctx, option.WithAuthCredentials(creds)) + if err != nil { + return nil, fmt.Errorf("failed to create play integrity client: %w", err) + } + + resp, err := svc.V1.DecodeIntegrityToken(packageName, + &playintegrity.DecodeIntegrityTokenRequest{IntegrityToken: token}).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("play integrity decode request failed: %w", err) + } + return resp.TokenPayloadExternal, nil +} diff --git a/backend/internal/attestation/error_constants.go b/backend/internal/attestation/error_constants.go new file mode 100644 index 0000000000..c6c64aa635 --- /dev/null +++ b/backend/internal/attestation/error_constants.go @@ -0,0 +1,36 @@ +/* + * 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 "errors" + +var ( + // errInvalidPayload is returned when the decoded integrity token does not carry the app + // integrity details required for verification. + errInvalidPayload = errors.New("integrity token payload is missing app integrity details") + // errPackageNameMismatch is returned when the attested package name does not match the + // registered package name. + errPackageNameMismatch = errors.New("attested package name does not match the registered package name") + // errSigningIdentityMismatch is returned when none of the attested signing certificate digests + // match the registered signing identity. + errSigningIdentityMismatch = errors.New("attested signing certificate does not match registered identity") + // 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") +) diff --git a/backend/internal/attestation/init.go b/backend/internal/attestation/init.go new file mode 100644 index 0000000000..e21aa48a1e --- /dev/null +++ b/backend/internal/attestation/init.go @@ -0,0 +1,29 @@ +/* + * 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 ( + kmprovider "github.com/thunder-id/thunderid/internal/system/kmprovider/common" + "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) +} diff --git a/backend/internal/attestation/integrityTokenDecoder_mock_test.go b/backend/internal/attestation/integrityTokenDecoder_mock_test.go new file mode 100644 index 0000000000..ef86e82e5a --- /dev/null +++ b/backend/internal/attestation/integrityTokenDecoder_mock_test.go @@ -0,0 +1,119 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package attestation + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "google.golang.org/api/playintegrity/v1" +) + +// newIntegrityTokenDecoderMock creates a new instance of integrityTokenDecoderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newIntegrityTokenDecoderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *integrityTokenDecoderMock { + mock := &integrityTokenDecoderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// integrityTokenDecoderMock is an autogenerated mock type for the integrityTokenDecoder type +type integrityTokenDecoderMock struct { + mock.Mock +} + +type integrityTokenDecoderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *integrityTokenDecoderMock) EXPECT() *integrityTokenDecoderMock_Expecter { + return &integrityTokenDecoderMock_Expecter{mock: &_m.Mock} +} + +// Decode provides a mock function for the type integrityTokenDecoderMock +func (_mock *integrityTokenDecoderMock) Decode(ctx context.Context, credentialsJSON string, packageName string, token string) (*playintegrity.TokenPayloadExternal, error) { + ret := _mock.Called(ctx, credentialsJSON, packageName, token) + + if len(ret) == 0 { + panic("no return value specified for Decode") + } + + var r0 *playintegrity.TokenPayloadExternal + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*playintegrity.TokenPayloadExternal, error)); ok { + return returnFunc(ctx, credentialsJSON, packageName, token) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *playintegrity.TokenPayloadExternal); ok { + r0 = returnFunc(ctx, credentialsJSON, packageName, token) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*playintegrity.TokenPayloadExternal) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = returnFunc(ctx, credentialsJSON, packageName, token) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// integrityTokenDecoderMock_Decode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decode' +type integrityTokenDecoderMock_Decode_Call struct { + *mock.Call +} + +// Decode is a helper method to define mock.On call +// - ctx context.Context +// - credentialsJSON string +// - packageName string +// - token string +func (_e *integrityTokenDecoderMock_Expecter) Decode(ctx interface{}, credentialsJSON interface{}, packageName interface{}, token interface{}) *integrityTokenDecoderMock_Decode_Call { + return &integrityTokenDecoderMock_Decode_Call{Call: _e.mock.On("Decode", ctx, credentialsJSON, packageName, token)} +} + +func (_c *integrityTokenDecoderMock_Decode_Call) Run(run func(ctx context.Context, credentialsJSON string, packageName string, token string)) *integrityTokenDecoderMock_Decode_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *integrityTokenDecoderMock_Decode_Call) Return(tokenPayloadExternal *playintegrity.TokenPayloadExternal, err error) *integrityTokenDecoderMock_Decode_Call { + _c.Call.Return(tokenPayloadExternal, err) + return _c +} + +func (_c *integrityTokenDecoderMock_Decode_Call) RunAndReturn(run func(ctx context.Context, credentialsJSON string, packageName string, token string) (*playintegrity.TokenPayloadExternal, error)) *integrityTokenDecoderMock_Decode_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/attestation/play_integrity.go b/backend/internal/attestation/play_integrity.go new file mode 100644 index 0000000000..bf539e49be --- /dev/null +++ b/backend/internal/attestation/play_integrity.go @@ -0,0 +1,165 @@ +/* + * 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 verifies the binary identity of a mobile client through platform-native +// attestation mechanisms (currently Google Play Integrity for Android). +package attestation + +import ( + "context" + "fmt" + "time" + + "github.com/thunder-id/thunderid/internal/system/cryptolib" + kmprovider "github.com/thunder-id/thunderid/internal/system/kmprovider/common" + "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" + + playintegrity "google.golang.org/api/playintegrity/v1" +) + +const ( + // appRecognitionVerdictPlayRecognized is the Play Integrity verdict indicating the app is a + // genuine binary distributed by Google Play. + appRecognitionVerdictPlayRecognized = "PLAY_RECOGNIZED" + + // verifyTimeout bounds the outbound verification call (which reaches Google's Play Integrity + // API) so a slow or unresponsive provider cannot stall flow initiation. Cancellation still + // propagates from the parent context. + verifyTimeout = 10 * time.Second +) + +// playIntegrityVerifier verifies Google Play Integrity tokens for Android clients. +type playIntegrityVerifier struct { + decoder integrityTokenDecoder + cryptoSvc kmprovider.RuntimeCryptoProvider + logger *log.Logger +} + +// newPlayIntegrityVerifier creates a platform attestation verifier backed by the given token +// decoder. +func newPlayIntegrityVerifier(decoder integrityTokenDecoder, + cryptoSvc kmprovider.RuntimeCryptoProvider) providers.AttestationProvider { + return &playIntegrityVerifier{ + decoder: decoder, + cryptoSvc: cryptoSvc, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "PlayIntegrityVerifier")), + } +} + +// Verify decrypts the stored service account credentials, decodes the Play Integrity token against +// Google's API within a bounded deadline, and checks that the attested app matches the application's +// registered package name and signing identity. It returns true only when every check passes; a +// definitive rejection (invalid payload, identity mismatch, or unrecognized app) is reported as +// (false, nil), while an operational failure (decrypt, decode, or configuration problem) is reported +// as (false, ServiceError). +func (v *playIntegrityVerifier) Verify(ctx context.Context, cfg *providers.AttestationConfig, token string) ( + bool, *tidcommon.ServiceError) { + if cfg == nil || cfg.Android == nil { + v.logger.Error(ctx, "Attestation requested without an attestation configuration") + return false, &tidcommon.InternalServerError + } + + // A configured application must register a package name, at least one signing certificate + // digest, and service account credentials; without all three the binary identity cannot be + // verified. Reject an incomplete configuration up front, before making an outbound call to + // Google's Play Integrity API. + android := cfg.Android + if android.PackageName == "" || len(android.CertificateSha256Digests) == 0 || + android.ServiceAccountCredentials == "" { + v.logger.Error(ctx, "Attestation configuration is incomplete") + return false, &tidcommon.InternalServerError + } + + decrypted, err := v.decryptConfig(ctx, android) + if err != nil { + v.logger.Error(ctx, "Failed to decrypt attestation credentials", log.Error(err)) + return false, &tidcommon.InternalServerError + } + + verifyCtx, cancel := context.WithTimeout(ctx, verifyTimeout) + defer cancel() + + payload, err := v.decoder.Decode(verifyCtx, decrypted.ServiceAccountCredentials, decrypted.PackageName, token) + if err != nil { + v.logger.Error(ctx, "Failed to decode play integrity token", log.Error(err)) + return false, &tidcommon.InternalServerError + } + + if err := verifyAndroidPayload(payload, decrypted); err != nil { + v.logger.Debug(ctx, "Attestation token rejected", log.Error(err)) + return false, nil + } + + return true, nil +} + +// decryptConfig returns a copy of the Android attestation config with the stored service account +// credentials decrypted, without mutating the shared stored profile. +func (v *playIntegrityVerifier) decryptConfig(ctx context.Context, cfg *providers.AndroidAttestationConfig) ( + *providers.AndroidAttestationConfig, error) { + android := *cfg + if android.ServiceAccountCredentials == "" { + return &android, nil + } + + params := cryptolib.AlgorithmParams{Algorithm: cryptolib.AlgorithmAESGCM} + plaintext, err := v.cryptoSvc.Decrypt(ctx, nil, params, []byte(android.ServiceAccountCredentials)) + if err != nil { + return nil, fmt.Errorf("failed to decrypt attestation credentials: %w", err) + } + android.ServiceAccountCredentials = string(plaintext) + return &android, nil +} + +// verifyAndroidPayload checks the decoded payload against the registered Android attestation config. +func verifyAndroidPayload(payload *playintegrity.TokenPayloadExternal, + android *providers.AndroidAttestationConfig) error { + if payload == nil || payload.AppIntegrity == nil { + return errInvalidPayload + } + + appIntegrity := payload.AppIntegrity + if appIntegrity.PackageName != android.PackageName { + return fmt.Errorf("%w (attested=%q, registered=%q)", + errPackageNameMismatch, appIntegrity.PackageName, android.PackageName) + } + if !hasCommonDigest(appIntegrity.CertificateSha256Digest, android.CertificateSha256Digests) { + return fmt.Errorf("%w (attested=%v, registered=%v)", + errSigningIdentityMismatch, appIntegrity.CertificateSha256Digest, android.CertificateSha256Digests) + } + if appIntegrity.AppRecognitionVerdict != appRecognitionVerdictPlayRecognized { + return errAppNotPlayRecognized + } + return nil +} + +// hasCommonDigest reports whether the attested and registered digest sets share at least one value. +func hasCommonDigest(attested, registered []string) bool { + registeredSet := make(map[string]struct{}, len(registered)) + for _, d := range registered { + registeredSet[d] = struct{}{} + } + for _, d := range attested { + if _, ok := registeredSet[d]; ok { + return true + } + } + return false +} diff --git a/backend/internal/attestation/play_integrity_test.go b/backend/internal/attestation/play_integrity_test.go new file mode 100644 index 0000000000..c6951e3c69 --- /dev/null +++ b/backend/internal/attestation/play_integrity_test.go @@ -0,0 +1,200 @@ +/* + * 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" + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + playintegrity "google.golang.org/api/playintegrity/v1" + + "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/thunder-id/thunderid/tests/mocks/crypto/cryptomock" +) + +const ( + testPackageName = "com.example.app" + testDigest = "AA:BB:CC" + testToken = "integrity-token" + testCredentials = "{\"type\":\"service_account\"}" + testEncrypted = "encrypted-creds" +) + +func androidConfig() *providers.AttestationConfig { + return &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{ + PackageName: testPackageName, + CertificateSha256Digests: []string{testDigest}, + ServiceAccountCredentials: testEncrypted, + }, + } +} + +func payload(pkg, verdict string, digests []string) *playintegrity.TokenPayloadExternal { + return &playintegrity.TokenPayloadExternal{ + AppIntegrity: &playintegrity.AppIntegrity{ + PackageName: pkg, + AppRecognitionVerdict: verdict, + CertificateSha256Digest: digests, + }, + } +} + +type PlayIntegrityVerifierTestSuite struct { + suite.Suite + decoder *integrityTokenDecoderMock + crypto *cryptomock.RuntimeCryptoProviderMock + verifier *playIntegrityVerifier +} + +func TestPlayIntegrityVerifierTestSuite(t *testing.T) { + suite.Run(t, new(PlayIntegrityVerifierTestSuite)) +} + +func (s *PlayIntegrityVerifierTestSuite) SetupTest() { + s.decoder = newIntegrityTokenDecoderMock(s.T()) + s.crypto = cryptomock.NewRuntimeCryptoProviderMock(s.T()) + s.verifier = &playIntegrityVerifier{ + decoder: s.decoder, + cryptoSvc: s.crypto, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "PlayIntegrityVerifier")), + } +} + +// expectDecrypt configures the crypto mock to decrypt the stored test credentials to their +// plaintext form, as every successful path must decrypt before decoding the token. +func (s *PlayIntegrityVerifierTestSuite) expectDecrypt() { + s.crypto.EXPECT().Decrypt(context.Background(), mock.Anything, mock.Anything, []byte(testEncrypted)). + Return([]byte(testCredentials), nil) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_Success() { + s.expectDecrypt() + s.decoder.EXPECT().Decode(mock.Anything, testCredentials, testPackageName, testToken). + Return(payload(testPackageName, "PLAY_RECOGNIZED", []string{testDigest}), nil) + + verified, svcErr := s.verifier.Verify(context.Background(), androidConfig(), testToken) + s.True(verified) + s.Nil(svcErr) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_NotConfigured() { + verified, svcErr := s.verifier.Verify(context.Background(), nil, testToken) + s.False(verified) + s.NotNil(svcErr) + + verified, svcErr = s.verifier.Verify(context.Background(), &providers.AttestationConfig{}, testToken) + s.False(verified) + s.NotNil(svcErr) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_DecryptError() { + s.crypto.EXPECT().Decrypt(context.Background(), mock.Anything, mock.Anything, []byte(testEncrypted)). + Return(nil, errors.New("decrypt failure")) + + verified, svcErr := s.verifier.Verify(context.Background(), androidConfig(), testToken) + s.False(verified) + s.NotNil(svcErr) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_DecoderError() { + s.expectDecrypt() + s.decoder.EXPECT().Decode(mock.Anything, testCredentials, testPackageName, testToken). + Return(nil, errors.New("google api failure")) + + verified, svcErr := s.verifier.Verify(context.Background(), androidConfig(), testToken) + s.False(verified) + s.NotNil(svcErr) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_InvalidPayload() { + s.expectDecrypt() + s.decoder.EXPECT().Decode(mock.Anything, testCredentials, testPackageName, testToken). + Return(&playintegrity.TokenPayloadExternal{}, nil) + + verified, svcErr := s.verifier.Verify(context.Background(), androidConfig(), testToken) + s.False(verified) + s.Nil(svcErr) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_PackageNameMismatch() { + s.expectDecrypt() + s.decoder.EXPECT().Decode(mock.Anything, testCredentials, testPackageName, testToken). + Return(payload("com.attacker.app", "PLAY_RECOGNIZED", []string{testDigest}), nil) + + verified, svcErr := s.verifier.Verify(context.Background(), androidConfig(), testToken) + s.False(verified) + s.Nil(svcErr) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_SigningIdentityMismatch() { + s.expectDecrypt() + s.decoder.EXPECT().Decode(mock.Anything, testCredentials, testPackageName, testToken). + Return(payload(testPackageName, "PLAY_RECOGNIZED", []string{"ZZ:ZZ:ZZ"}), nil) + + verified, svcErr := s.verifier.Verify(context.Background(), androidConfig(), testToken) + s.False(verified) + s.Nil(svcErr) +} + +func (s *PlayIntegrityVerifierTestSuite) TestVerify_AppNotPlayRecognized() { + s.expectDecrypt() + s.decoder.EXPECT().Decode(mock.Anything, testCredentials, testPackageName, testToken). + Return(payload(testPackageName, "UNRECOGNIZED_VERSION", []string{testDigest}), nil) + + verified, svcErr := s.verifier.Verify(context.Background(), androidConfig(), testToken) + s.False(verified) + s.Nil(svcErr) +} + +// TestVerify_IncompleteConfig ensures that a configuration missing the package name, the signing +// certificate digests, or the service account credentials is rejected as incomplete before any +// outbound call is made — a partial configuration cannot establish binary identity, and neither the +// credential decryption nor the Play Integrity API call should be reached. +func (s *PlayIntegrityVerifierTestSuite) TestVerify_IncompleteConfig() { + cases := map[string]*providers.AndroidAttestationConfig{ + "missing package name": { + CertificateSha256Digests: []string{testDigest}, + ServiceAccountCredentials: testEncrypted, + }, + "missing certificate digests": { + PackageName: testPackageName, + ServiceAccountCredentials: testEncrypted, + }, + "missing service account credentials": { + PackageName: testPackageName, + CertificateSha256Digests: []string{testDigest}, + }, + } + + for name, android := range cases { + s.Run(name, func() { + s.SetupTest() + + cfg := &providers.AttestationConfig{Android: android} + verified, svcErr := s.verifier.Verify(context.Background(), cfg, testToken) + s.False(verified) + s.NotNil(svcErr) + }) + } +} diff --git a/backend/internal/flow/flowexec/FlowExecServiceInterface_mock_test.go b/backend/internal/flow/flowexec/FlowExecServiceInterface_mock_test.go index 1e1e6ed976..e5f3143cb8 100644 --- a/backend/internal/flow/flowexec/FlowExecServiceInterface_mock_test.go +++ b/backend/internal/flow/flowexec/FlowExecServiceInterface_mock_test.go @@ -39,8 +39,8 @@ func (_m *FlowExecServiceInterfaceMock) EXPECT() *FlowExecServiceInterfaceMock_E } // Execute provides a mock function for the type FlowExecServiceInterfaceMock -func (_mock *FlowExecServiceInterfaceMock) Execute(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string) (*FlowStep, *common.ServiceError) { - ret := _mock.Called(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) +func (_mock *FlowExecServiceInterfaceMock) Execute(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string, attestationToken string) (*FlowStep, *common.ServiceError) { + ret := _mock.Called(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) if len(ret) == 0 { panic("no return value specified for Execute") @@ -48,18 +48,18 @@ func (_mock *FlowExecServiceInterfaceMock) Execute(ctx context.Context, appID st var r0 *FlowStep var r1 *common.ServiceError - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string) (*FlowStep, *common.ServiceError)); ok { - return returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string, string) (*FlowStep, *common.ServiceError)); ok { + return returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string) *FlowStep); ok { - r0 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string, string) *FlowStep); ok { + r0 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*FlowStep) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, bool, string, map[string]string, string, string) *common.ServiceError); ok { - r1 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, bool, string, map[string]string, string, string, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*common.ServiceError) @@ -83,11 +83,12 @@ type FlowExecServiceInterfaceMock_Execute_Call struct { // - inputs map[string]string // - challengeToken string // - flowSecret string -func (_e *FlowExecServiceInterfaceMock_Expecter) Execute(ctx interface{}, appID interface{}, executionID interface{}, flowType interface{}, verbose interface{}, action interface{}, inputs interface{}, challengeToken interface{}, flowSecret interface{}) *FlowExecServiceInterfaceMock_Execute_Call { - return &FlowExecServiceInterfaceMock_Execute_Call{Call: _e.mock.On("Execute", ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret)} +// - attestationToken string +func (_e *FlowExecServiceInterfaceMock_Expecter) Execute(ctx interface{}, appID interface{}, executionID interface{}, flowType interface{}, verbose interface{}, action interface{}, inputs interface{}, challengeToken interface{}, flowSecret interface{}, attestationToken interface{}) *FlowExecServiceInterfaceMock_Execute_Call { + return &FlowExecServiceInterfaceMock_Execute_Call{Call: _e.mock.On("Execute", ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken)} } -func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string)) *FlowExecServiceInterfaceMock_Execute_Call { +func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string, attestationToken string)) *FlowExecServiceInterfaceMock_Execute_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -125,6 +126,10 @@ func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Co if args[8] != nil { arg8 = args[8].(string) } + var arg9 string + if args[9] != nil { + arg9 = args[9].(string) + } run( arg0, arg1, @@ -135,6 +140,7 @@ func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Co arg6, arg7, arg8, + arg9, ) }) return _c @@ -145,7 +151,7 @@ func (_c *FlowExecServiceInterfaceMock_Execute_Call) Return(flowStep *FlowStep, return _c } -func (_c *FlowExecServiceInterfaceMock_Execute_Call) RunAndReturn(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string) (*FlowStep, *common.ServiceError)) *FlowExecServiceInterfaceMock_Execute_Call { +func (_c *FlowExecServiceInterfaceMock_Execute_Call) RunAndReturn(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string, attestationToken string) (*FlowStep, *common.ServiceError)) *FlowExecServiceInterfaceMock_Execute_Call { _c.Call.Return(run) return _c } diff --git a/backend/internal/flow/flowexec/constants.go b/backend/internal/flow/flowexec/constants.go index 9136708faf..46c3fd2647 100644 --- a/backend/internal/flow/flowexec/constants.go +++ b/backend/internal/flow/flowexec/constants.go @@ -43,4 +43,9 @@ const ( // in by redirect, or an embedded app with no protocol profile at all — that may initiate a flow // directly by presenting a valid Flow Secret. flowInitiationFlowSecret + // flowInitiationAttestation indicates a mobile application that may initiate a flow directly by + // presenting a valid platform attestation (e.g. a Google Play Integrity token) proving its binary + // identity. This takes precedence over the redirect-based classification for apps that configure + // attestation. + flowInitiationAttestation ) diff --git a/backend/internal/flow/flowexec/error_constants.go b/backend/internal/flow/flowexec/error_constants.go index ee18452430..92fd365e32 100644 --- a/backend/internal/flow/flowexec/error_constants.go +++ b/backend/internal/flow/flowexec/error_constants.go @@ -209,3 +209,33 @@ var ErrorMaxCallDepthExceeded = tidcommon.ServiceError{ DefaultValue: "The maximum allowed call depth has been exceeded during flow execution", }, } + +// ErrorAttestationRequired defines the error when a mobile application initiates a new flow without +// presenting a platform attestation token. +var ErrorAttestationRequired = tidcommon.ServiceError{ + Code: "FES-1014", + Type: tidcommon.ClientErrorType, + Error: tidcommon.I18nMessage{ + Key: "error.flowexecservice.attestation_required", + DefaultValue: "Authentication required", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.flowexecservice.attestation_required_description", + DefaultValue: "Mobile applications must present a valid attestation token to initiate a new flow", + }, +} + +// ErrorAttestationInvalid defines the error when a mobile application presents an attestation token +// that fails verification. +var ErrorAttestationInvalid = tidcommon.ServiceError{ + Code: "FES-1015", + Type: tidcommon.ClientErrorType, + Error: tidcommon.I18nMessage{ + Key: "error.flowexecservice.attestation_invalid", + DefaultValue: "Authentication failed", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.flowexecservice.attestation_invalid_description", + DefaultValue: "The provided attestation token is invalid", + }, +} diff --git a/backend/internal/flow/flowexec/handler.go b/backend/internal/flow/flowexec/handler.go index 503ad506b0..43148776fb 100644 --- a/backend/internal/flow/flowexec/handler.go +++ b/backend/internal/flow/flowexec/handler.go @@ -68,13 +68,15 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter, inputs := sysutils.SanitizeStringMap(flowR.Inputs) challengeToken := sysutils.SanitizeString(flowR.ChallengeToken) flowSecret := sysutils.SanitizeString(r.Header.Get(serverconst.FlowSecretHeaderName)) + attestationToken := sysutils.SanitizeString(r.Header.Get(serverconst.AttestationTokenHeaderName)) // Read the inbound SSO transport inputs (per-flow handle cookies) and make // them available to the flow service, which selects the handle once the flow is known. ctx := session.WithInbound(r.Context(), h.ssoTransport.Read(r)) flowStep, flowErr := h.flowExecService.Execute( - ctx, appID, executionID, flowTypeStr, verbose, action, inputs, challengeToken, flowSecret) + ctx, appID, executionID, flowTypeStr, verbose, action, inputs, challengeToken, + flowSecret, attestationToken) if flowErr != nil { handleFlowError(r.Context(), w, flowErr) @@ -127,7 +129,8 @@ func handleFlowError(ctx context.Context, w http.ResponseWriter, flowErr *tidcom switch flowErr.Code { case ErrorDirectFlowInitiationNotPermitted.Code: statusCode = http.StatusForbidden - case ErrorFlowSecretRequired.Code, ErrorFlowSecretInvalid.Code: + case ErrorFlowSecretRequired.Code, ErrorFlowSecretInvalid.Code, + ErrorAttestationRequired.Code, ErrorAttestationInvalid.Code: statusCode = http.StatusUnauthorized default: statusCode = http.StatusBadRequest diff --git a/backend/internal/flow/flowexec/handler_test.go b/backend/internal/flow/flowexec/handler_test.go index 3223b842d8..6feb2ee2f6 100644 --- a/backend/internal/flow/flowexec/handler_test.go +++ b/backend/internal/flow/flowexec/handler_test.go @@ -120,7 +120,7 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_ServiceError() { t := s.T() mockSvc := NewFlowExecServiceInterfaceMock(t) mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(nil, &ErrorDirectFlowInitiationNotPermitted) h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) @@ -140,7 +140,7 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_Success() { Status: providers.FlowStatusIncomplete, } mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(flowStep, (*tidcommon.ServiceError)(nil)) h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) @@ -161,9 +161,9 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_PropagatesInboundSSOCo var gotInbound session.InboundHandle var gotOK bool mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Run(func(ctx context.Context, _ string, _ string, _ string, _ bool, _ string, - _ map[string]string, _ string, _ string) { + _ map[string]string, _ string, _ string, _ string) { gotInbound, gotOK = session.InboundFrom(ctx) }). Return(&FlowStep{ExecutionID: "exec-1", Status: providers.FlowStatusIncomplete}, @@ -194,7 +194,7 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_WritesSSOHandleCookie( SSOFlowID: "flow-1", } mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(flowStep, (*tidcommon.ServiceError)(nil)) // secure=true and a non-zero TTL so the emitted cookie carries the expected transport settings. @@ -220,6 +220,26 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_WritesSSOHandleCookie( s.True(ssoCookie.HttpOnly) } +// The Attestation-Token request header must be read and forwarded to the service layer as the +// attestation token argument. +func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_AttestationTokenHeaderForwarded() { + t := s.T() + mockSvc := NewFlowExecServiceInterfaceMock(t) + mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, "play-integrity-token"). + Return(&FlowStep{ExecutionID: "exec-1", Status: providers.FlowStatusIncomplete}, + (*tidcommon.ServiceError)(nil)) + + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) + req := httptest.NewRequest(http.MethodPost, "/flow/execute", bytes.NewBufferString(testFlowExecRequestBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Attestation-Token", "play-integrity-token") + w := httptest.NewRecorder() + + h.HandleFlowExecutionRequest(w, req) + s.Equal(http.StatusOK, w.Code) +} + func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_StepWithError() { t := s.T() mockSvc := NewFlowExecServiceInterfaceMock(t) @@ -236,7 +256,7 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_StepWithError() { Error: stepErr, } mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(flowStep, (*tidcommon.ServiceError)(nil)) h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) diff --git a/backend/internal/flow/flowexec/init.go b/backend/internal/flow/flowexec/init.go index 7684f9aea6..ee78581d2b 100644 --- a/backend/internal/flow/flowexec/init.go +++ b/backend/internal/flow/flowexec/init.go @@ -41,6 +41,7 @@ func Initialize( interceptorRegistry interceptor.InterceptorRegistryInterface, observabilitySvc providers.ObservabilityProvider, cryptoSvc kmprovider.RuntimeCryptoProvider, + attestationVerifier providers.AttestationProvider, graphBuilder graphbuilder.GraphBuilderInterface, storeProvider providers.RuntimeStoreProvider, transactioner transaction.Transactioner, @@ -51,7 +52,8 @@ func Initialize( flowEngine := newFlowEngine(executorRegistry, interceptorRunner, observabilitySvc, flowProvider, graphBuilder) flowExecService := newFlowExecService(flowProvider, flowStore, flowEngine, - actorProvider, observabilitySvc, transactioner, cryptoSvc, graphBuilder, cfg) + actorProvider, observabilitySvc, transactioner, cryptoSvc, attestationVerifier, + graphBuilder, cfg) // Mark the SSO cookie Secure unless the deployment is configured to serve over plain HTTP, and // bound its lifetime to the session's configured absolute timeout (same fallback as the session diff --git a/backend/internal/flow/flowexec/interface.go b/backend/internal/flow/flowexec/interface.go index e76229db33..72a2fca2a6 100644 --- a/backend/internal/flow/flowexec/interface.go +++ b/backend/internal/flow/flowexec/interface.go @@ -28,7 +28,7 @@ import ( // entry point for flow execution. type FlowExecServiceInterface interface { Execute(ctx context.Context, appID, executionID, flowType string, verbose bool, - action string, inputs map[string]string, challengeToken, flowSecret string) ( + action string, inputs map[string]string, challengeToken, flowSecret, attestationToken string) ( *FlowStep, *tidcommon.ServiceError) InitiateFlow(ctx context.Context, initContext *FlowInitContext) (string, *tidcommon.ServiceError) InitiateAndExecute(ctx context.Context, initContext *FlowInitContext) (*FlowStep, *tidcommon.ServiceError) diff --git a/backend/internal/flow/flowexec/service.go b/backend/internal/flow/flowexec/service.go index 07758b93e3..9b3fec323b 100644 --- a/backend/internal/flow/flowexec/service.go +++ b/backend/internal/flow/flowexec/service.go @@ -46,15 +46,16 @@ import ( // flowExecService is the implementation of FlowExecServiceInterface type flowExecService struct { - flowEngine flowEngineInterface - flowProvider providers.FlowProvider - graphBuilder graphbuilder.GraphBuilderInterface - flowStore flowStoreInterface - actorProvider providers.ActorProvider - observabilitySvc providers.ObservabilityProvider - transactioner transaction.Transactioner - cryptoSvc kmprovider.RuntimeCryptoProvider - cfg flowconfig.Config + flowEngine flowEngineInterface + flowProvider providers.FlowProvider + graphBuilder graphbuilder.GraphBuilderInterface + flowStore flowStoreInterface + actorProvider providers.ActorProvider + observabilitySvc providers.ObservabilityProvider + transactioner transaction.Transactioner + cryptoSvc kmprovider.RuntimeCryptoProvider + attestationVerifier providers.AttestationProvider + cfg flowconfig.Config } // newFlowExecService creates a new instance of flowExecService with the provided dependencies. @@ -64,25 +65,27 @@ func newFlowExecService(flowProvider providers.FlowProvider, observabilitySvc providers.ObservabilityProvider, transactioner transaction.Transactioner, cryptoSvc kmprovider.RuntimeCryptoProvider, + attestationVerifier providers.AttestationProvider, graphBuilder graphbuilder.GraphBuilderInterface, cfg flowconfig.Config) FlowExecServiceInterface { return &flowExecService{ - flowProvider: flowProvider, - flowStore: flowStore, - flowEngine: flowEngine, - actorProvider: actorProvider, - observabilitySvc: observabilitySvc, - transactioner: transactioner, - cryptoSvc: cryptoSvc, - graphBuilder: graphBuilder, - cfg: cfg, + flowProvider: flowProvider, + flowStore: flowStore, + flowEngine: flowEngine, + actorProvider: actorProvider, + observabilitySvc: observabilitySvc, + transactioner: transactioner, + cryptoSvc: cryptoSvc, + attestationVerifier: attestationVerifier, + graphBuilder: graphBuilder, + cfg: cfg, } } // Execute executes a flow with the given data func (s *flowExecService) Execute(ctx context.Context, appID, executionID, flowType string, verbose bool, - action string, inputs map[string]string, challengeToken, flowSecret string) ( + action string, inputs map[string]string, challengeToken, flowSecret, attestationToken string) ( *FlowStep, *tidcommon.ServiceError) { logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "FlowExecService")) @@ -93,7 +96,8 @@ func (s *flowExecService) Execute(ctx context.Context, var loadErr *tidcommon.ServiceError if isNewFlow(executionID) { - engineCtx, loadErr = s.loadNewContext(ctx, appID, flowType, verbose, action, inputs, flowSecret, logger) + engineCtx, loadErr = s.loadNewContext(ctx, appID, flowType, verbose, action, inputs, + flowSecret, attestationToken, logger) if loadErr != nil { logger.Error(ctx, "Failed to load new flow context", log.String("appID", appID), @@ -188,14 +192,15 @@ func applyInboundSSO(engineCtx *EngineContext, ctx context.Context) { // initContext initializes a new flow context with the given details. func (s *flowExecService) loadNewContext(ctx context.Context, appID, flowTypeStr string, verbose bool, - action string, inputs map[string]string, flowSecret string, logger *log.Logger) ( + action string, inputs map[string]string, flowSecret, attestationToken string, logger *log.Logger) ( *EngineContext, *tidcommon.ServiceError) { flowType, err := validateFlowType(flowTypeStr) if err != nil { return nil, err } - if svcErr := s.checkDirectFlowInitiationAllowed(ctx, appID, flowType, flowSecret, logger); svcErr != nil { + if svcErr := s.checkDirectFlowInitiationAllowed( + ctx, appID, flowType, flowSecret, attestationToken, logger); svcErr != nil { return nil, svcErr } @@ -215,12 +220,15 @@ func (s *flowExecService) loadNewContext(ctx context.Context, appID, flowTypeStr // component, not via a direct HTTP call. // - FlowSecret — a backend / server-side application (including embedded apps with no protocol // profile) that must authenticate at flow initiation by presenting its Flow Secret. +// - Attestation — a mobile application that authenticates at flow initiation by presenting a valid +// platform attestation (e.g. a Google Play Integrity token) proving its binary identity. // // Other flow types (registration, recovery, user onboarding) are not restricted. The classification -// is derived from neutral actor data resolved through the actor layer; Flow Secret verification is -// the only credential check that remains here. +// is derived from neutral actor data resolved through the actor layer; credential verification +// (Flow Secret or attestation) is the only check that remains here. func (s *flowExecService) checkDirectFlowInitiationAllowed(ctx context.Context, appID string, - flowType providers.FlowType, flowSecret string, logger *log.Logger) *tidcommon.ServiceError { + flowType providers.FlowType, flowSecret, attestationToken string, + logger *log.Logger) *tidcommon.ServiceError { if flowType != providers.FlowTypeAuthentication { return nil } @@ -228,7 +236,7 @@ func (s *flowExecService) checkDirectFlowInitiationAllowed(ctx context.Context, return nil } - mode, svcErr := s.resolveFlowInitiationMode(ctx, appID) + mode, attestationCfg, svcErr := s.resolveFlowInitiationMode(ctx, appID) if svcErr != nil { if svcErr.Code == actorprovider.ErrorActorNotFound.Code { return &ErrorInvalidAppID @@ -253,6 +261,8 @@ func (s *flowExecService) checkDirectFlowInitiationAllowed(ctx context.Context, return &ErrorFlowSecretInvalid } return nil + case flowInitiationAttestation: + return s.verifyAttestation(ctx, attestationCfg, attestationToken) default: logger.Error(ctx, "Unknown flow initiation mode for application", log.String("appID", appID)) @@ -260,26 +270,56 @@ func (s *flowExecService) checkDirectFlowInitiationAllowed(ctx context.Context, } } +// verifyAttestation validates the platform attestation token presented by a mobile application at +// flow initiation. Decrypting the stored service account credentials, bounding the verification +// call with a deadline, and calling the Play Integrity API are all handled by the attestation +// provider. +func (s *flowExecService) verifyAttestation(ctx context.Context, + attestationCfg *providers.AttestationConfig, attestationToken string) *tidcommon.ServiceError { + if attestationToken == "" { + return &ErrorAttestationRequired + } + + verified, svcErr := s.attestationVerifier.Verify(ctx, attestationCfg, attestationToken) + if svcErr != nil { + return svcErr + } + if !verified { + return &ErrorAttestationInvalid + } + return nil +} + // resolveFlowInitiationMode derives how the given application is permitted to initiate a new // authentication flow, using neutral actor data resolved through the actor layer. A non-existent // application returns ErrorActorNotFound so the caller can distinguish an unknown app from a -// backend app. +// backend app. The resolved OAuth profile (nil for embedded apps) is returned for downstream +// credential checks. func (s *flowExecService) resolveFlowInitiationMode( ctx context.Context, appID string, -) (flowInitiationMode, *tidcommon.ServiceError) { +) (flowInitiationMode, *providers.AttestationConfig, *tidcommon.ServiceError) { + // The inbound client is protocol-agnostic and exists for every valid application. Platform + // attestation is a client-level binary-identity check, so it is resolved here first and takes + // precedence regardless of whether the application also has an OAuth2 protocol profile. An + // unknown application surfaces as ErrorActorNotFound so the caller can map it to an invalid app. + client, clientErr := s.actorProvider.GetInboundClientByID(ctx, appID) + if clientErr != nil { + return 0, nil, clientErr + } + if client.Attestation != nil && client.Attestation.Android != nil { + return flowInitiationAttestation, client.Attestation, nil + } + + // No attestation configured: classify by protocol profile. profile, svcErr := s.actorProvider.GetOAuthProfileByID(ctx, appID) if svcErr != nil && svcErr.Code != actorprovider.ErrorActorNotFound.Code { - return 0, svcErr + return 0, nil, svcErr } - // No protocol profile means either a server-side embedded app (exists, no OAuth config) or a - // non-existent application. Confirm existence so an unknown app surfaces as ErrorActorNotFound - // rather than being treated as a backend app. + // No protocol profile means a server-side embedded app: it initiates flows directly by + // presenting its Flow Secret. if profile == nil { - if _, clientErr := s.actorProvider.GetInboundClientByID(ctx, appID); clientErr != nil { - return 0, clientErr - } - return flowInitiationFlowSecret, nil + return flowInitiationFlowSecret, nil, nil } // A redirect-based (authorization_code) profile — public or confidential — must initiate flows @@ -288,9 +328,9 @@ func (s *flowExecService) resolveFlowInitiationMode( // may initiate a flow directly. if slices.Contains(profile.GrantTypes, string(providers.GrantTypeAuthorizationCode)) || isClientCredentialsOnly(profile.GrantTypes) { - return flowInitiationNotPermitted, nil + return flowInitiationNotPermitted, nil, nil } - return flowInitiationFlowSecret, nil + return flowInitiationFlowSecret, nil, nil } // isClientCredentialsOnly reports whether client_credentials is the only configured grant type. diff --git a/backend/internal/flow/flowexec/service_test.go b/backend/internal/flow/flowexec/service_test.go index 34c885a3d7..218c130573 100644 --- a/backend/internal/flow/flowexec/service_test.go +++ b/backend/internal/flow/flowexec/service_test.go @@ -51,6 +51,7 @@ import ( "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/thunder-id/thunderid/tests/mocks/actorprovidermock" + "github.com/thunder-id/thunderid/tests/mocks/attestationprovidermock" "github.com/thunder-id/thunderid/tests/mocks/authnprovider/managermock" "github.com/thunder-id/thunderid/tests/mocks/crypto/cryptomock" "github.com/thunder-id/thunderid/tests/mocks/entityprovidermock" @@ -777,7 +778,7 @@ func TestDecryptCalledForEncryptedStoredContext(t *testing.T) { } flowStep, svcErr := service.Execute(context.Background(), "test-app", existingExecutionID, - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "", "") assert.Nil(t, svcErr) assert.NotNil(t, flowStep) @@ -1005,7 +1006,7 @@ func TestExecute_ContextDecryptionFailure(t *testing.T) { } _, svcErr := service.Execute(context.Background(), "test-app", existingExecutionID, - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "", "") assert.NotNil(t, svcErr) assert.Equal(t, tidcommon.InternalServerError.Code, svcErr.Code) @@ -1073,7 +1074,7 @@ func TestExecute_ContextDecryptionSuccess(t *testing.T) { } flowStep, svcErr := service.Execute(context.Background(), "test-app", existingExecutionID, - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, challengeToken, "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, challengeToken, "", "") assert.Nil(t, svcErr) assert.NotNil(t, flowStep) @@ -1142,7 +1143,7 @@ func TestExecute_ExistingFlowWithoutChallengeToken(t *testing.T) { // Execute with empty challenge token flowStep, svcErr := service.Execute(context.Background(), "test-app", existingExecutionID, - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "", "") assert.Nil(t, svcErr) assert.NotNil(t, flowStep) @@ -1236,7 +1237,8 @@ func TestExecute_ExistingFlowWithDifferentChallengeTokens(t *testing.T) { } flowStep, svcErr := service.Execute(context.Background(), "test-app", existingExecutionID, - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, tt.challengeToken, "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, + tt.challengeToken, "", "") assert.Nil(t, svcErr) assert.NotNil(t, flowStep) @@ -1312,7 +1314,7 @@ func TestExecute_EngineError_InvalidChallengeToken_PreservesContext(t *testing.T } flowStep, svcErr := service.Execute(context.Background(), "test-app", existingExecutionID, - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "wrong-token", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "wrong-token", "", "") assert.Nil(t, svcErr) assert.NotNil(t, flowStep) @@ -1388,7 +1390,7 @@ func TestExecute_EngineError_NonChallengeToken_RemovesContext(t *testing.T) { } flowStep, svcErr := service.Execute(context.Background(), "test-app", existingExecutionID, - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "valid-token", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "valid-token", "", "") assert.NotNil(t, svcErr) assert.Equal(t, otherErr.Code, svcErr.Code) @@ -1451,7 +1453,7 @@ func TestExecute_EngineError_NewFlow_ContextNeverRemoved(t *testing.T) { // Pass empty executionID to indicate a new flow flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret", "") assert.Nil(t, svcErr) assert.NotNil(t, flowStep) @@ -2169,7 +2171,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_IncompleteStoresContext() { } flowStep, svcErr := service.Execute(context.Background(), appID, "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret", "") s.Nil(svcErr) s.NotNil(flowStep) @@ -2230,7 +2232,7 @@ func (s *ServiceTestSuite) TestExecute_ExistingFlow_CompleteRemovesContext() { } flowStep, svcErr := service.Execute(context.Background(), "test-app", "existing-execution-id", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "", "") s.Nil(svcErr) s.NotNil(flowStep) @@ -2241,7 +2243,7 @@ func (s *ServiceTestSuite) TestLoadNewContext_InvalidFlowType() { service := &flowExecService{cfg: testFlowExecCfg} engineCtx, svcErr := service.loadNewContext(context.Background(), "test-app", "INVALID_TYPE", - false, "submit", map[string]string{}, "", log.GetLogger()) + false, "submit", map[string]string{}, "", "", log.GetLogger()) s.Nil(engineCtx) s.NotNil(svcErr) @@ -2396,6 +2398,8 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_GuardRejections() { mockActorProvider := actorprovidermock.NewActorProviderMock(t) mockObservability := observabilitymock.NewObservabilityServiceInterfaceMock(t) + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "test-app").Return( + &providers.InboundClient{ID: "test-app"}, nil) mockActorProvider.EXPECT().GetOAuthProfileByID(mock.Anything, "test-app").Return( &providers.OAuthProfile{GrantTypes: tc.grantTypes}, nil) mockObservability.EXPECT().IsEnabled().Return(false) @@ -2407,7 +2411,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_GuardRejections() { } flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", tc.flowSecret) + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", tc.flowSecret, "") s.Nil(flowStep) s.NotNil(svcErr) @@ -2443,7 +2447,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_BackendApp_ValidSecret_Allowed() mock.Anything, mock.Anything, mock.Anything). Return(providers.AuthUser{}, nil, nil) mockInboundClient.EXPECT().GetInboundClientByEntityID(mock.Anything, "test-app").Return( - &inboundmodel.InboundClient{ID: "test-app", AuthFlowID: "auth-graph-1"}, nil).Times(2) + &inboundmodel.InboundClient{ID: "test-app", AuthFlowID: "auth-graph-1"}, nil).Times(3) mockEntityProvider.EXPECT().GetEntity("test-app").Return( &providers.Entity{ID: "test-app", Category: providers.EntityCategoryApp}, (*entityprovider.EntityProviderError)(nil)) @@ -2465,7 +2469,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_BackendApp_ValidSecret_Allowed() } flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret", "") s.Nil(svcErr) s.NotNil(flowStep) @@ -2479,6 +2483,8 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_BackendApp_InvalidSecret_Rejected mockActorProvider := actorprovidermock.NewActorProviderMock(t) mockObservability := observabilitymock.NewObservabilityServiceInterfaceMock(t) + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "test-app").Return( + &providers.InboundClient{ID: "test-app"}, nil) mockActorProvider.EXPECT().GetOAuthProfileByID(mock.Anything, "test-app").Return( &providers.OAuthProfile{ GrantTypes: []string{"client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"}, @@ -2496,7 +2502,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_BackendApp_InvalidSecret_Rejected } flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "wrong-secret") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "wrong-secret", "") s.Nil(flowStep) s.NotNil(svcErr) @@ -2512,6 +2518,8 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_InitiationModeError_InternalError mockActorProvider := actorprovidermock.NewActorProviderMock(t) mockObservability := observabilitymock.NewObservabilityServiceInterfaceMock(t) + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "test-app").Return( + &providers.InboundClient{ID: "test-app"}, nil) mockActorProvider.EXPECT().GetOAuthProfileByID(mock.Anything, "test-app").Return( (*providers.OAuthProfile)(nil), &tidcommon.InternalServerError) mockObservability.EXPECT().IsEnabled().Return(false) @@ -2523,7 +2531,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_InitiationModeError_InternalError } flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "", "") s.Nil(flowStep) s.NotNil(svcErr) @@ -2577,7 +2585,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_EmbeddedApp_ValidSecret_Allowed() } flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "valid-secret", "") s.Nil(svcErr) s.NotNil(flowStep) @@ -2606,7 +2614,7 @@ func (s *ServiceTestSuite) TestExecute_NewFlow_EmbeddedApp_MissingSecret_Rejecte } flowStep, svcErr := service.Execute(context.Background(), "test-app", "", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "", "", "") s.Nil(flowStep) s.NotNil(svcErr) @@ -2667,7 +2675,7 @@ func (s *ServiceTestSuite) TestExecute_ContinuationFlow_AuthCodeApp_NotBlocked() } flowStep, svcErr := service.Execute(context.Background(), "test-app", "existing-execution-id", - string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "valid-token", "") + string(providers.FlowTypeAuthentication), false, "submit", map[string]string{}, "valid-token", "", "") s.Nil(svcErr) s.NotNil(flowStep) @@ -2703,7 +2711,7 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_NoProfileRequire } svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "embedded-app", - providers.FlowTypeAuthentication, "", log.GetLogger()) + providers.FlowTypeAuthentication, "", "", log.GetLogger()) s.NotNil(svcErr) s.Equal(ErrorFlowSecretRequired.Code, svcErr.Code) } @@ -2713,8 +2721,6 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_NoProfileRequire func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_UnknownAppInvalidAppID() { t := s.T() mockActorProvider := actorprovidermock.NewActorProviderMock(t) - mockActorProvider.EXPECT().GetOAuthProfileByID(mock.Anything, "app-notfound").Return( - (*providers.OAuthProfile)(nil), &actorprovider.ErrorActorNotFound) mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "app-notfound").Return( (*providers.InboundClient)(nil), &actorprovider.ErrorActorNotFound) @@ -2724,7 +2730,7 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_UnknownAppInvali } svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "app-notfound", - providers.FlowTypeAuthentication, "", log.GetLogger()) + providers.FlowTypeAuthentication, "", "", log.GetLogger()) s.NotNil(svcErr) s.Equal(ErrorInvalidAppID.Code, svcErr.Code) } @@ -2733,7 +2739,110 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_NonAuthFlowAllow service := &flowExecService{cfg: testFlowExecCfg} svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "test-app", - providers.FlowTypeRegistration, "", log.GetLogger()) + providers.FlowTypeRegistration, "", "", log.GetLogger()) + s.Nil(svcErr) +} + +// attestationClient returns an inbound client configured with Android attestation, holding an +// already-encrypted service account credential. +func attestationClient() *providers.InboundClient { + return &providers.InboundClient{ + ID: "mobile-app", + Attestation: &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{ + PackageName: "com.example.app", + ServiceAccountCredentials: "encrypted-creds", + }, + }, + } +} + +// A mobile app with attestation configured but no token presented is rejected before any +// verification is attempted. +func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AttestationMissingToken() { + t := s.T() + mockActorProvider := actorprovidermock.NewActorProviderMock(t) + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "mobile-app").Return( + attestationClient(), nil) + + service := &flowExecService{ + actorProvider: mockActorProvider, + attestationVerifier: attestationprovidermock.NewAttestationProviderMock(t), + cfg: testFlowExecCfg, + } + + svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "mobile-app", + providers.FlowTypeAuthentication, "", "", log.GetLogger()) + s.NotNil(svcErr) + s.Equal(ErrorAttestationRequired.Code, svcErr.Code) +} + +// A token that is definitively rejected by the provider (identity mismatch, unrecognized app, etc.) +// surfaces as an invalid attestation error. +func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AttestationInvalid() { + t := s.T() + mockActorProvider := actorprovidermock.NewActorProviderMock(t) + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "mobile-app").Return( + attestationClient(), nil) + mockProvider := attestationprovidermock.NewAttestationProviderMock(t) + mockProvider.EXPECT().Verify(mock.Anything, mock.Anything, "bad-token"). + Return(false, nil) + + service := &flowExecService{ + actorProvider: mockActorProvider, + attestationVerifier: mockProvider, + cfg: testFlowExecCfg, + } + + svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "mobile-app", + providers.FlowTypeAuthentication, "", "bad-token", log.GetLogger()) + s.NotNil(svcErr) + s.Equal(ErrorAttestationInvalid.Code, svcErr.Code) +} + +// An operational provider failure (provider outage, decrypt failure, misconfiguration) is surfaced +// as a retriable server error, not a 401 — the caller should not treat a provider outage as an +// authentication failure. +func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AttestationVerifierUnavailable() { + t := s.T() + mockActorProvider := actorprovidermock.NewActorProviderMock(t) + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "mobile-app").Return( + attestationClient(), nil) + mockProvider := attestationprovidermock.NewAttestationProviderMock(t) + mockProvider.EXPECT().Verify(mock.Anything, mock.Anything, "some-token"). + Return(false, &tidcommon.InternalServerError) + + service := &flowExecService{ + actorProvider: mockActorProvider, + attestationVerifier: mockProvider, + cfg: testFlowExecCfg, + } + + svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "mobile-app", + providers.FlowTypeAuthentication, "", "some-token", log.GetLogger()) + s.NotNil(svcErr) + s.Equal(tidcommon.InternalServerError.Code, svcErr.Code) +} + +// A token verified by the attestation provider permits direct flow initiation. +func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AttestationValid() { + t := s.T() + mockActorProvider := actorprovidermock.NewActorProviderMock(t) + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "mobile-app").Return( + attestationClient(), nil) + mockProvider := attestationprovidermock.NewAttestationProviderMock(t) + mockProvider.EXPECT().Verify(mock.Anything, mock.MatchedBy(func(cfg *providers.AttestationConfig) bool { + return cfg != nil && cfg.Android != nil && cfg.Android.ServiceAccountCredentials == "encrypted-creds" + }), "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) } diff --git a/backend/internal/inboundclient/store.go b/backend/internal/inboundclient/store.go index 851724f69a..d4501f78cd 100644 --- a/backend/internal/inboundclient/store.go +++ b/backend/internal/inboundclient/store.go @@ -40,6 +40,7 @@ type inboundClientJSONBlob struct { Assertion *inboundmodel.AssertionConfig `json:"assertion,omitempty"` LoginConsent *inboundmodel.LoginConsentConfig `json:"loginConsent,omitempty"` AllowedUserTypes []string `json:"allowedUserTypes,omitempty"` + Attestation *providers.AttestationConfig `json:"attestation,omitempty"` Properties map[string]interface{} `json:"properties,omitempty"` } @@ -113,6 +114,7 @@ func marshalInboundClient(c inboundmodel.InboundClient) ( Assertion: c.Assertion, LoginConsent: c.LoginConsent, AllowedUserTypes: c.AllowedUserTypes, + Attestation: c.Attestation, Properties: c.Properties, } propertiesBytes, err = marshalNullableJSON(blob) @@ -493,6 +495,7 @@ func buildInboundClientFromRow(ctx context.Context, row map[string]interface{}) client.Assertion = blob.Assertion client.LoginConsent = blob.LoginConsent client.AllowedUserTypes = blob.AllowedUserTypes + client.Attestation = blob.Attestation client.Properties = blob.Properties } } diff --git a/backend/internal/system/constants/server_constants.go b/backend/internal/system/constants/server_constants.go index fc5dab3c02..c84d6154cb 100644 --- a/backend/internal/system/constants/server_constants.go +++ b/backend/internal/system/constants/server_constants.go @@ -39,6 +39,10 @@ const CorrelationIDHeaderName = "X-Correlation-ID" // a flow directly over HTTP. const FlowSecretHeaderName = "Flow-Secret" +// AttestationTokenHeaderName is the name of the header used to present a platform attestation token +// (e.g. a Google Play Integrity token) when a mobile application initiates a flow directly over HTTP. +const AttestationTokenHeaderName = "Attestation-Token" + // TokenTypeBearer is the token type used in bearer authentication. const TokenTypeBearer = "Bearer" diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index 86119ff671..1e0f01390c 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -553,6 +553,10 @@ var defaultMessages = map[string]string{ "error.flow.graphbuilder.invalid_flow_data_nil_or_empty_description": "Flow definition is nil or has no nodes", "error.flowexecservice.application_retrieval_error": "Application retrieval error", "error.flowexecservice.application_retrieval_error_description": "Error while retrieving application details", + "error.flowexecservice.attestation_invalid": "Authentication failed", + "error.flowexecservice.attestation_invalid_description": "The provided attestation token is invalid", + "error.flowexecservice.attestation_required": "Authentication required", + "error.flowexecservice.attestation_required_description": "Mobile applications must present a valid attestation token to initiate a new flow", "error.flowexecservice.direct_flow_initiation_not_permitted": "Direct flow initiation not permitted", "error.flowexecservice.direct_flow_initiation_not_permitted_description": "Direct flow initiation is not permitted for this application type", "error.flowexecservice.flow_secret_invalid": "Authentication failed", diff --git a/backend/pkg/thunderidengine/engine.go b/backend/pkg/thunderidengine/engine.go index 97c1c07545..e126448433 100644 --- a/backend/pkg/thunderidengine/engine.go +++ b/backend/pkg/thunderidengine/engine.go @@ -25,6 +25,7 @@ import ( "net/http" "time" + "github.com/thunder-id/thunderid/internal/attestation" "github.com/thunder-id/thunderid/internal/attributecache" "github.com/thunder-id/thunderid/internal/authn/assert" flowconfig "github.com/thunder-id/thunderid/internal/flow/config" @@ -138,9 +139,11 @@ func New(mux *http.ServeMux, opts ...Option) *Engine { engineCtx.graphBuilder = graphbuilder.Initialize(engineCtx.flowFactory, engineCtx.execRegistry, engineCtx.interceptorRegistry, graphCache) + attestationProvider := attestation.Initialize(engineCtx.runtimeCryptoSvc) flowExecService, err := flowexec.Initialize(mux, engineCtx.flowProvider, engineCtx.actorProvider, engineCtx.execRegistry, engineCtx.interceptorRegistry, engineCtx.observabilitySvc, - engineCtx.runtimeCryptoSvc, engineCtx.graphBuilder, runtimeStoreProvider, transactioner, flowConfig) + engineCtx.runtimeCryptoSvc, attestationProvider, engineCtx.graphBuilder, runtimeStoreProvider, + transactioner, flowConfig) if err != nil { logger.Fatal(ctx, "Failed to initialize flow execution service", log.Error(err)) } diff --git a/backend/pkg/thunderidengine/providers/interface.go b/backend/pkg/thunderidengine/providers/interface.go index 441e58d1fd..23ed05ff59 100644 --- a/backend/pkg/thunderidengine/providers/interface.go +++ b/backend/pkg/thunderidengine/providers/interface.go @@ -193,6 +193,18 @@ type AuthorizationProvider interface { ) (*AccessEvaluationsResponse, *common.ServiceError) } +// AttestationProvider verifies a platform attestation token (e.g. a Google Play Integrity token) +// against an application's attestation configuration, proving the binary identity of a mobile +// client. It reports the verification outcome as a boolean rather than an error so that a definitive +// rejection (token invalid) is not mistaken for an operational failure (provider outage, +// misconfiguration): the latter is surfaced as a non-nil ServiceError. +type AttestationProvider interface { + // Verify returns true when the token proves the expected binary identity. It returns false with + // a nil error for a definitive rejection, and a non-nil ServiceError for an operational failure + // that prevented verification from completing. + Verify(ctx context.Context, cfg *AttestationConfig, token string) (bool, *common.ServiceError) +} + // RuntimeStoreProvider defines the interface for runtime store operations. type RuntimeStoreProvider interface { // Put stores a value in the runtime store with the specified key and TTL (time-to-live) in seconds. diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index 672c422e0f..443078b088 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -643,6 +643,35 @@ type Certificate struct { Value string `json:"value,omitempty" yaml:"value,omitempty" jsonschema:"Certificate value in the format specified by type."` } +// AttestationConfig holds per-application platform attestation settings used to verify the binary +// 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."` +} + +// AndroidAttestationConfig holds the Google Play Integrity settings for an Android application. +type AndroidAttestationConfig struct { + PackageName string `json:"packageName,omitempty" yaml:"packageName,omitempty" jsonschema:"Android application package name that must match the attested app."` + CertificateSha256Digests []string `json:"certificateSha256Digests,omitempty" yaml:"certificateSha256Digests,omitempty" jsonschema:"Allowed SHA-256 digests of the app signing certificate. The attested app must match one of these."` + 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."` +} + +// 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 { + if c == nil { + return nil + } + sanitized := &AttestationConfig{} + if c.Android != nil { + android := *c.Android + android.ServiceAccountCredentials = "" + android.CertificateSha256Digests = append([]string(nil), c.Android.CertificateSha256Digests...) + sanitized.Android = &android + } + return sanitized +} + // OAuthProfile is the persistence shape (OAUTH_PROFILE JSONB column). type OAuthProfile struct { RedirectURIs []string `json:"redirectUris"` @@ -675,8 +704,11 @@ type InboundClient struct { Assertion *AssertionConfig LoginConsent *LoginConsentConfig AllowedUserTypes []string - Properties map[string]interface{} - IsReadOnly bool + // Attestation holds the optional platform attestation config that lets a mobile client prove + // its binary identity to initiate a flow directly, independent of any protocol profile. + Attestation *AttestationConfig + Properties map[string]interface{} + IsReadOnly bool } // AssertionConfig is the entity-level assertion config; token configs fall back to it. @@ -984,6 +1016,7 @@ type InboundAuthProfile struct { Assertion *AssertionConfig `json:"assertion,omitempty" yaml:"assertion,omitempty" jsonschema:"Assertion configuration. Optional. Customize assertion validity periods and included user attributes."` LoginConsent *LoginConsentConfig `json:"loginConsent,omitempty" yaml:"loginConsent,omitempty" jsonschema:"Login consent configuration settings."` AllowedUserTypes []string `json:"allowedUserTypes,omitempty" yaml:"allowedUserTypes,omitempty" jsonschema:"Allowed user types. Optional. Restricts which user types can authenticate to and register against this resource."` + Attestation *AttestationConfig `json:"attestation,omitempty" yaml:"attestation,omitempty" jsonschema:"Platform attestation configuration. Optional. Enables a mobile client to initiate flows directly by proving its binary identity (e.g. Google Play Integrity), regardless of protocol. The service account credentials are write-only and never returned in responses."` } // OAuthConfigWithSecret is the wire input shape and the create/update echo response shape. diff --git a/backend/tests/mocks/attestationprovidermock/AttestationProvider_mock.go b/backend/tests/mocks/attestationprovidermock/AttestationProvider_mock.go new file mode 100644 index 0000000000..2e6789645d --- /dev/null +++ b/backend/tests/mocks/attestationprovidermock/AttestationProvider_mock.go @@ -0,0 +1,114 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package attestationprovidermock + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// NewAttestationProviderMock creates a new instance of AttestationProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAttestationProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *AttestationProviderMock { + mock := &AttestationProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// AttestationProviderMock is an autogenerated mock type for the AttestationProvider type +type AttestationProviderMock struct { + mock.Mock +} + +type AttestationProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *AttestationProviderMock) EXPECT() *AttestationProviderMock_Expecter { + return &AttestationProviderMock_Expecter{mock: &_m.Mock} +} + +// Verify provides a mock function for the type AttestationProviderMock +func (_mock *AttestationProviderMock) Verify(ctx context.Context, cfg *providers.AttestationConfig, token string) (bool, *common.ServiceError) { + ret := _mock.Called(ctx, cfg, token) + + if len(ret) == 0 { + panic("no return value specified for Verify") + } + + var r0 bool + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, *providers.AttestationConfig, string) (bool, *common.ServiceError)); ok { + return returnFunc(ctx, cfg, token) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *providers.AttestationConfig, string) bool); ok { + r0 = returnFunc(ctx, cfg, token) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *providers.AttestationConfig, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, cfg, token) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// AttestationProviderMock_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' +type AttestationProviderMock_Verify_Call struct { + *mock.Call +} + +// Verify is a helper method to define mock.On call +// - ctx context.Context +// - cfg *providers.AttestationConfig +// - token string +func (_e *AttestationProviderMock_Expecter) Verify(ctx interface{}, cfg interface{}, token interface{}) *AttestationProviderMock_Verify_Call { + return &AttestationProviderMock_Verify_Call{Call: _e.mock.On("Verify", ctx, cfg, token)} +} + +func (_c *AttestationProviderMock_Verify_Call) Run(run func(ctx context.Context, cfg *providers.AttestationConfig, token string)) *AttestationProviderMock_Verify_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *providers.AttestationConfig + if args[1] != nil { + arg1 = args[1].(*providers.AttestationConfig) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *AttestationProviderMock_Verify_Call) Return(b bool, serviceError *common.ServiceError) *AttestationProviderMock_Verify_Call { + _c.Call.Return(b, serviceError) + return _c +} + +func (_c *AttestationProviderMock_Verify_Call) RunAndReturn(run func(ctx context.Context, cfg *providers.AttestationConfig, token string) (bool, *common.ServiceError)) *AttestationProviderMock_Verify_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/tests/mocks/flow/flowexecmock/FlowExecServiceInterface_mock.go b/backend/tests/mocks/flow/flowexecmock/FlowExecServiceInterface_mock.go index e78bb62dc3..299e0ed738 100644 --- a/backend/tests/mocks/flow/flowexecmock/FlowExecServiceInterface_mock.go +++ b/backend/tests/mocks/flow/flowexecmock/FlowExecServiceInterface_mock.go @@ -40,8 +40,8 @@ func (_m *FlowExecServiceInterfaceMock) EXPECT() *FlowExecServiceInterfaceMock_E } // Execute provides a mock function for the type FlowExecServiceInterfaceMock -func (_mock *FlowExecServiceInterfaceMock) Execute(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string) (*flowexec.FlowStep, *common.ServiceError) { - ret := _mock.Called(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) +func (_mock *FlowExecServiceInterfaceMock) Execute(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string, attestationToken string) (*flowexec.FlowStep, *common.ServiceError) { + ret := _mock.Called(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) if len(ret) == 0 { panic("no return value specified for Execute") @@ -49,18 +49,18 @@ func (_mock *FlowExecServiceInterfaceMock) Execute(ctx context.Context, appID st var r0 *flowexec.FlowStep var r1 *common.ServiceError - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string) (*flowexec.FlowStep, *common.ServiceError)); ok { - return returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string, string) (*flowexec.FlowStep, *common.ServiceError)); ok { + return returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string) *flowexec.FlowStep); ok { - r0 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, bool, string, map[string]string, string, string, string) *flowexec.FlowStep); ok { + r0 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*flowexec.FlowStep) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, bool, string, map[string]string, string, string) *common.ServiceError); ok { - r1 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, bool, string, map[string]string, string, string, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*common.ServiceError) @@ -84,11 +84,12 @@ type FlowExecServiceInterfaceMock_Execute_Call struct { // - inputs map[string]string // - challengeToken string // - flowSecret string -func (_e *FlowExecServiceInterfaceMock_Expecter) Execute(ctx interface{}, appID interface{}, executionID interface{}, flowType interface{}, verbose interface{}, action interface{}, inputs interface{}, challengeToken interface{}, flowSecret interface{}) *FlowExecServiceInterfaceMock_Execute_Call { - return &FlowExecServiceInterfaceMock_Execute_Call{Call: _e.mock.On("Execute", ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret)} +// - attestationToken string +func (_e *FlowExecServiceInterfaceMock_Expecter) Execute(ctx interface{}, appID interface{}, executionID interface{}, flowType interface{}, verbose interface{}, action interface{}, inputs interface{}, challengeToken interface{}, flowSecret interface{}, attestationToken interface{}) *FlowExecServiceInterfaceMock_Execute_Call { + return &FlowExecServiceInterfaceMock_Execute_Call{Call: _e.mock.On("Execute", ctx, appID, executionID, flowType, verbose, action, inputs, challengeToken, flowSecret, attestationToken)} } -func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string)) *FlowExecServiceInterfaceMock_Execute_Call { +func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string, attestationToken string)) *FlowExecServiceInterfaceMock_Execute_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -126,6 +127,10 @@ func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Co if args[8] != nil { arg8 = args[8].(string) } + var arg9 string + if args[9] != nil { + arg9 = args[9].(string) + } run( arg0, arg1, @@ -136,6 +141,7 @@ func (_c *FlowExecServiceInterfaceMock_Execute_Call) Run(run func(ctx context.Co arg6, arg7, arg8, + arg9, ) }) return _c @@ -146,7 +152,7 @@ func (_c *FlowExecServiceInterfaceMock_Execute_Call) Return(flowStep *flowexec.F return _c } -func (_c *FlowExecServiceInterfaceMock_Execute_Call) RunAndReturn(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string) (*flowexec.FlowStep, *common.ServiceError)) *FlowExecServiceInterfaceMock_Execute_Call { +func (_c *FlowExecServiceInterfaceMock_Execute_Call) RunAndReturn(run func(ctx context.Context, appID string, executionID string, flowType string, verbose bool, action string, inputs map[string]string, challengeToken string, flowSecret string, attestationToken string) (*flowexec.FlowStep, *common.ServiceError)) *FlowExecServiceInterfaceMock_Execute_Call { _c.Call.Return(run) return _c } diff --git a/backend/tests/mocks/flow/flowexecmock/attestationVerifier_mock.go b/backend/tests/mocks/flow/flowexecmock/attestationVerifier_mock.go new file mode 100644 index 0000000000..03f3a3d8de --- /dev/null +++ b/backend/tests/mocks/flow/flowexecmock/attestationVerifier_mock.go @@ -0,0 +1,102 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowexecmock + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// newAttestationVerifierMock creates a new instance of attestationVerifierMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newAttestationVerifierMock(t interface { + mock.TestingT + Cleanup(func()) +}) *attestationVerifierMock { + mock := &attestationVerifierMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// attestationVerifierMock is an autogenerated mock type for the attestationVerifier type +type attestationVerifierMock struct { + mock.Mock +} + +type attestationVerifierMock_Expecter struct { + mock *mock.Mock +} + +func (_m *attestationVerifierMock) EXPECT() *attestationVerifierMock_Expecter { + return &attestationVerifierMock_Expecter{mock: &_m.Mock} +} + +// Verify provides a mock function for the type attestationVerifierMock +func (_mock *attestationVerifierMock) Verify(ctx context.Context, cfg *providers.AttestationConfig, token string) error { + ret := _mock.Called(ctx, cfg, token) + + if len(ret) == 0 { + panic("no return value specified for Verify") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *providers.AttestationConfig, string) error); ok { + r0 = returnFunc(ctx, cfg, token) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// attestationVerifierMock_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' +type attestationVerifierMock_Verify_Call struct { + *mock.Call +} + +// Verify is a helper method to define mock.On call +// - ctx context.Context +// - cfg *providers.AttestationConfig +// - token string +func (_e *attestationVerifierMock_Expecter) Verify(ctx interface{}, cfg interface{}, token interface{}) *attestationVerifierMock_Verify_Call { + return &attestationVerifierMock_Verify_Call{Call: _e.mock.On("Verify", ctx, cfg, token)} +} + +func (_c *attestationVerifierMock_Verify_Call) Run(run func(ctx context.Context, cfg *providers.AttestationConfig, token string)) *attestationVerifierMock_Verify_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *providers.AttestationConfig + if args[1] != nil { + arg1 = args[1].(*providers.AttestationConfig) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *attestationVerifierMock_Verify_Call) Return(err error) *attestationVerifierMock_Verify_Call { + _c.Call.Return(err) + return _c +} + +func (_c *attestationVerifierMock_Verify_Call) RunAndReturn(run func(ctx context.Context, cfg *providers.AttestationConfig, token string) error) *attestationVerifierMock_Verify_Call { + _c.Call.Return(run) + return _c +} diff --git a/docs/content/guides/guides/applications/application-settings.mdx b/docs/content/guides/guides/applications/application-settings.mdx index 88248878ec..846e84a117 100644 --- a/docs/content/guides/guides/applications/application-settings.mdx +++ b/docs/content/guides/guides/applications/application-settings.mdx @@ -130,6 +130,20 @@ 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 + +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: + +| Setting | Description | +|---------|-------------| +| **Package Name** | The Android application package name (for example, `com.example.myapp`) that must match the attested app. | +| **Signing Certificate SHA-256 Digests** | One or more allowed signing certificate digests, in the URL-safe base64 form reported by Play Integrity. The attested app must match one of these. | +| **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. +::: + ## 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 f8e327bb33..5a992adf76 100644 --- a/docs/content/guides/key-concepts/authentication/integration-models.mdx +++ b/docs/content/guides/key-concepts/authentication/integration-models.mdx @@ -80,6 +80,27 @@ Registration, recovery, and user onboarding flows are **not** restricted because See the [API Reference](../../../../apis#tag/flow-execution) for endpoint specifications. +### 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. + +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. + +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: + +```http +POST /flow/execute +Content-Type: application/json +Attestation-Token: + +{ + "applicationId": "", + "flowType": "AUTHENTICATION" +} +``` + +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. + ### Integration Modes App-native authentication supports two modes, **Verbose** and **Non-Verbose**, controlled via the `verbose` field in the Flow Execution API. These modes offer different levels of UI granularity to the application. A SDK typically consumes these modes and manages API calls, state, and response parsing. Both modes follow the same request-response cycle: the application calls the Flow Execution API, receives the next step, renders the screen, collects user input, and submits the response back. The difference lies in how much detail returns for each step. 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 new file mode 100644 index 0000000000..285d5e9c9a --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx @@ -0,0 +1,235 @@ +/** + * 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. + */ + +import {SettingsCard} from '@thunderid/components'; +import {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'; + +/** + * Props for the {@link AttestationSection} component. + */ +interface AttestationSectionProps { + /** + * The current attestation config (from the OAuth config). + * null or undefined means no attestation is configured. + */ + attestation?: AttestationConfig | null; + /** + * Called when the user changes any attestation field. + * Passes null when no attestation fields are set. + */ + onAttestationChange: (attestation: AttestationConfig | null) => void; + /** + * Whether inputs should be disabled (e.g. read-only resource). + */ + disabled?: boolean; +} + +/** + * Section component for configuring Google Play Integrity attestation for Android 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. + * + * @param props - Component props + * @returns Attestation configuration UI within a SettingsCard + */ +export default function AttestationSection({ + attestation = undefined, + onAttestationChange, + disabled = false, +}: AttestationSectionProps) { + const {t} = useTranslation(); + + const android = attestation?.android; + // 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 [packageName, setPackageName] = useState(android?.packageName ?? ''); + const [digests, setDigests] = useState(android?.certificateSha256Digests ?? []); + const [credentials, setCredentials] = useState(''); + + // 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 lastSyncedKeyRef = useRef(identityKey); + + useEffect(() => { + if (identityKey === lastSyncedKeyRef.current) { + return; + } + lastSyncedKeyRef.current = identityKey; + setPackageName(android?.packageName ?? ''); + setDigests(android?.certificateSha256Digests ?? []); + // 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. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identityKey]); + + const emit = (pkg: string, digs: string[], creds: string) => { + const cleanedDigests = digs.map((d) => d.trim()).filter((d) => d !== ''); + const cleanedPackageName = pkg.trim(); + + // 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}); + + if (cleanedPackageName === '' && cleanedDigests.length === 0 && creds === '') { + onAttestationChange(null); + return; + } + + const androidConfig: NonNullable = {}; + if (cleanedPackageName !== '') { + androidConfig.packageName = cleanedPackageName; + } + if (cleanedDigests.length > 0) { + androidConfig.certificateSha256Digests = cleanedDigests; + } + if (creds !== '') { + androidConfig.serviceAccountCredentials = creds; + } + onAttestationChange({android: androidConfig}); + }; + + const handlePackageNameChange = (value: string) => { + setPackageName(value); + emit(value, digests, credentials); + }; + + const handleCredentialsChange = (value: string) => { + setCredentials(value); + emit(packageName, digests, value); + }; + + const commitDigests = (nextDigests: string[]) => { + setDigests(nextDigests); + emit(packageName, nextDigests, credentials); + }; + + const handleAddDigest = () => { + setDigests((prev) => [...prev, '']); + }; + + const handleDigestChange = (index: number, value: string) => { + setDigests((prev) => prev.map((d, i) => (i === index ? value : d))); + }; + + const handleRemoveDigest = (index: number) => { + commitDigests(digests.filter((_, i) => i !== index)); + }; + + return ( + + + + + {t('applications:edit.advanced.attestation.labels.packageName')} + + handlePackageNameChange(e.target.value)} + placeholder={t('applications:edit.advanced.attestation.placeholder.packageName')} + helperText={t('applications:edit.advanced.attestation.hint.packageName')} + 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')} + disabled={disabled} + /> + + + handleRemoveDigest(index)} color="error" sx={{mt: 1}} disabled={disabled}> + + + + + ))} + + + + + + + + + {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} + /> + + + + ); +} 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 3c34c05d6e..52099e5078 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,13 +17,14 @@ */ import {Stack} from '@wso2/oxygen-ui'; +import AttestationSection from './AttestationSection'; import CertificateSection from './CertificateSection'; import MetadataSection from './MetadataSection'; import OAuth2ConfigSection from './OAuth2ConfigSection'; import type {Application} from '../../../models/application'; import type {ApplicationTemplate} from '../../../models/application-templates'; import type {InboundAuthConfig} from '../../../models/inbound-auth'; -import type {OAuth2Config} from '../../../models/oauth'; +import type {AttestationConfig, OAuth2Config} from '../../../models/oauth'; /** * Props for the {@link EditAdvancedSettings} component. @@ -56,6 +57,11 @@ interface EditAdvancedSettingsProps { * discovery's `grant_types_supported`. Omit to offer every discovery-advertised grant type. */ allowedGrantTypes?: string[]; + /** + * Whether the platform attestation section is shown. Driven by the template's `attestation` + * capability, so it appears only for templates that support it (e.g. mobile). + */ + showAttestation?: boolean; } type OAuthCertificate = {type: string; value?: string} | null; @@ -78,6 +84,7 @@ export default function EditAdvancedSettings({ oauth2Constraints = undefined, onFieldChange, allowedGrantTypes = undefined, + showAttestation = false, }: EditAdvancedSettingsProps) { const handleOAuth2ConfigChange = (updates: Partial) => { const currentInboundAuth: InboundAuthConfig[] = editedApp.inboundAuthConfig ?? application.inboundAuthConfig ?? []; @@ -91,6 +98,17 @@ export default function EditAdvancedSettings({ handleOAuth2ConfigChange({certificate: cert}); }; + // Attestation is a client-level (protocol-agnostic) setting, so it is stored at the top level of + // the application rather than nested under the OAuth2 config. This lets any application type — + // including embedded apps with no OAuth2 config — enable it. + const handleAttestationChange = (attestation: AttestationConfig | null) => { + onFieldChange('attestation', attestation); + }; + + // Prefer the edited value whenever it has been set — including an explicit null, which represents + // the user clearing attestation. Only fall back to the stored value when the field is untouched. + const currentAttestation = 'attestation' in editedApp ? editedApp.attestation : application.attestation; + return ( + {showAttestation && ( + + )} ); 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 new file mode 100644 index 0000000000..3cd3a57ad4 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.roundtrip.test.tsx @@ -0,0 +1,57 @@ +/** + * 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. + */ + +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {useState} from 'react'; +import {describe, it, expect, vi} from 'vitest'; +import type {AttestationConfig} from '../../../../models/oauth'; +import AttestationSection from '../AttestationSection'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: string) => key}), +})); + +// 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. +function Harness() { + const [attestation, setAttestation] = useState(undefined); + return ; +} + +describe('AttestationSection round-trip', () => { + it('lets the user type the package name and reflects it back', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('applications:edit.advanced.attestation.labels.packageName'); + await user.type(input, 'com.example.app'); + + expect(input).toHaveValue('com.example.app'); + }); + + it('lets the user type the service account credentials', async () => { + const user = userEvent.setup(); + render(); + + const creds = screen.getByLabelText('applications:edit.advanced.attestation.labels.serviceAccountCredentials'); + await user.type(creds, 'abc123'); + + expect(creds).toHaveValue('abc123'); + }); +}); 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 new file mode 100644 index 0000000000..350cb7543b --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx @@ -0,0 +1,137 @@ +/** + * 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. + */ + +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import type {AttestationConfig} from '../../../../models/oauth'; +import AttestationSection from '../AttestationSection'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('AttestationSection', () => { + const mockOnAttestationChange = vi.fn(); + + beforeEach(() => { + mockOnAttestationChange.mockClear(); + }); + + describe('Rendering', () => { + it('should render the attestation section', () => { + render(); + + expect(screen.getByText('applications:edit.advanced.labels.attestation')).toBeInTheDocument(); + expect(screen.getByText('applications:edit.advanced.attestation.intro')).toBeInTheDocument(); + }); + + it('should render the package name and credentials fields', () => { + render(); + + expect(screen.getByLabelText('applications:edit.advanced.attestation.labels.packageName')).toBeInTheDocument(); + expect( + screen.getByLabelText('applications:edit.advanced.attestation.labels.serviceAccountCredentials'), + ).toBeInTheDocument(); + }); + + it('should render the configured package name and digests', () => { + render( + , + ); + + expect(screen.getByDisplayValue('com.example.app')).toBeInTheDocument(); + expect(screen.getByDisplayValue('AA:BB')).toBeInTheDocument(); + expect(screen.getByDisplayValue('CC:DD')).toBeInTheDocument(); + }); + + it('should not render the service account credentials value even when configured', () => { + // The credentials field is write-only; the component never displays a stored value. + render( + , + ); + + expect(screen.queryByDisplayValue('secret-json')).not.toBeInTheDocument(); + }); + }); + + describe('Editing', () => { + it('should emit an attestation config when the package name is set', 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. + const input = screen.getByLabelText('applications:edit.advanced.attestation.labels.packageName'); + await user.type(input, 'x'); + + expect(mockOnAttestationChange).toHaveBeenLastCalledWith({android: {packageName: 'x'}}); + }); + + it('should emit null when the only configured value is cleared', async () => { + const user = userEvent.setup({delay: null}); + render( + , + ); + + const input = screen.getByLabelText('applications:edit.advanced.attestation.labels.packageName'); + await user.clear(input); + + expect(mockOnAttestationChange).toHaveBeenLastCalledWith(null); + }); + + it('should add a digest row when Add Digest is clicked', async () => { + const user = userEvent.setup({delay: null}); + render(); + + await user.click(screen.getByText('applications:edit.advanced.attestation.addDigest')); + + expect( + screen.getByPlaceholderText('applications:edit.advanced.attestation.placeholder.certificateSha256Digest'), + ).toBeInTheDocument(); + }); + + it('should emit the service account credentials when entered', async () => { + const user = userEvent.setup({delay: null}); + render( + , + ); + + const creds = screen.getByLabelText('applications:edit.advanced.attestation.labels.serviceAccountCredentials'); + await user.type(creds, '{{"type":"service_account"}'); + + const calls = mockOnAttestationChange.mock.calls as [AttestationConfig | null][]; + const lastArg = calls[calls.length - 1][0]; + expect(lastArg?.android?.serviceAccountCredentials).toBe('{"type":"service_account"}'); + }); + }); +}); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx index 1007fd858f..5c99e0e266 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx @@ -76,6 +76,33 @@ describe('EditAdvancedSettings', () => { expect(screen.getByText('applications:edit.advanced.labels.metadata')).toBeInTheDocument(); }); + it('should not render the attestation section by default', () => { + render( + , + ); + + expect(screen.queryByText('applications:edit.advanced.labels.attestation')).not.toBeInTheDocument(); + }); + + it('should render the attestation section when the template supports it', () => { + render( + , + ); + + expect(screen.getByText('applications:edit.advanced.labels.attestation')).toBeInTheDocument(); + }); + it('should render without OAuth2 config when not provided', () => { render(); diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json index 428c02b8f5..b35fecdd64 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json @@ -25,5 +25,8 @@ "pkceRequired": {"readOnly": true, "value": true}, "tokenEndpointAuthMethod": {"readOnly": true, "value": "none"} } + }, + "capabilities": { + "attestation": true } } diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json index 3884e71ede..70035e2c76 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json @@ -28,5 +28,8 @@ "pkceRequired": {"readOnly": true, "value": true}, "tokenEndpointAuthMethod": {"readOnly": true, "value": "none"} } + }, + "capabilities": { + "attestation": true } } diff --git a/frontend/apps/console/src/features/applications/models/application-templates.ts b/frontend/apps/console/src/features/applications/models/application-templates.ts index 5a9774013a..74b73060a4 100644 --- a/frontend/apps/console/src/features/applications/models/application-templates.ts +++ b/frontend/apps/console/src/features/applications/models/application-templates.ts @@ -202,6 +202,16 @@ export interface ApplicationTemplate { }; }; }; + /** + * Optional per-template capability flags that gate optional configuration surfaces in the edit UI. + */ + capabilities?: { + /** + * Whether this template supports platform attestation (e.g. Google Play Integrity). When true, + * the attestation configuration section is shown in the application's advanced settings. + */ + attestation?: boolean; + }; /** * Optional integration guides for this template */ diff --git a/frontend/apps/console/src/features/applications/models/application.ts b/frontend/apps/console/src/features/applications/models/application.ts index 8cbcdfa1ac..d4ca6ee8c3 100644 --- a/frontend/apps/console/src/features/applications/models/application.ts +++ b/frontend/apps/console/src/features/applications/models/application.ts @@ -17,6 +17,7 @@ */ import type {InboundAuthConfig} from './inbound-auth'; +import type {AttestationConfig} from './oauth'; import type {AssertionConfig} from './token'; /** @@ -300,6 +301,14 @@ export interface Application { */ assertion?: AssertionConfig; + /** + * Platform attestation configuration used to verify the binary identity of a mobile client + * when it initiates a flow directly, independent of the OAuth2 protocol. The service account + * credentials are write-only and never returned in GET responses. null or undefined means no + * attestation is configured. + */ + attestation?: AttestationConfig | null; + /** * Whether this application is read-only (declarative/immutable) */ diff --git a/frontend/apps/console/src/features/applications/models/oauth.ts b/frontend/apps/console/src/features/applications/models/oauth.ts index 17a30f86ee..3b48c91f56 100644 --- a/frontend/apps/console/src/features/applications/models/oauth.ts +++ b/frontend/apps/console/src/features/applications/models/oauth.ts @@ -435,6 +435,38 @@ export interface OAuth2Config { acrValues?: string[]; } +/** + * Platform attestation configuration for an application. + */ +export interface AttestationConfig { + /** + * Google Play Integrity attestation configuration for Android clients. + */ + android?: AndroidAttestationConfig; +} + +/** + * Google Play Integrity attestation settings for an Android application. + */ +export interface AndroidAttestationConfig { + /** + * Android application package name that must match the attested app. + */ + packageName?: string; + + /** + * Allowed SHA-256 digests of the app signing certificate. The attested app must match one of + * these. Values use the URL-safe base64 (no padding) form reported by the Play Integrity API. + */ + certificateSha256Digests?: string[]; + + /** + * Google Cloud service account credentials (JSON) used to call the Play Integrity API. + * Write-only: this is never returned by the API, so it is absent when loading an application. + */ + serviceAccountCredentials?: 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 bd3f17ccda..9500c20fb6 100644 --- a/frontend/apps/console/src/features/applications/pages/ApplicationEditPage.tsx +++ b/frontend/apps/console/src/features/applications/pages/ApplicationEditPage.tsx @@ -51,6 +51,7 @@ import {McpClientTypes} from '../models/mcp-client'; import type {OAuth2Config} from '../models/oauth'; import deriveMcpClientType from '../utils/deriveMcpClientType'; import {getIntegrationGuideForTemplate} from '../utils/getIntegrationGuidesForTemplate'; +import getTemplateCapabilities from '../utils/getTemplateCapabilities'; import getTemplateFieldConstraints from '../utils/getTemplateFieldConstraints'; import getTemplateMetadata from '../utils/getTemplateMetadata'; @@ -131,6 +132,12 @@ export default function ApplicationEditPage() { [application?.template], ); + // Attestation is offered only for templates that declare the capability (e.g. mobile). + const supportsAttestation = useMemo( + () => Boolean(getTemplateCapabilities(application?.template)?.attestation), + [application?.template], + ); + const handleFieldChange = useCallback((field: keyof Application, value: unknown) => { setEditedApp((prev) => ({...prev, [field]: value})); }, []); @@ -551,6 +558,7 @@ export default function ApplicationEditPage() { oauth2Config={oauth2Config} oauth2Constraints={oauth2Constraints} onFieldChange={handleFieldChange} + showAttestation={supportsAttestation} /> diff --git a/frontend/apps/console/src/features/applications/utils/getTemplateCapabilities.ts b/frontend/apps/console/src/features/applications/utils/getTemplateCapabilities.ts new file mode 100644 index 0000000000..17e4459a7d --- /dev/null +++ b/frontend/apps/console/src/features/applications/utils/getTemplateCapabilities.ts @@ -0,0 +1,51 @@ +/** + * 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. + */ + +import normalizeTemplateId from './normalizeTemplateId'; +import PlatformBasedApplicationTemplateMetadata from '../config/PlatformBasedApplicationTemplateMetadata'; +import TechnologyBasedApplicationTemplateMetadata from '../config/TechnologyBasedApplicationTemplateMetadata'; +import type {ApplicationTemplate} from '../models/application-templates'; + +/** + * Gets the capability flags for a given template ID. + * Automatically normalizes template IDs by removing the '-embedded' suffix, so 'mobile-embedded' + * resolves to the 'mobile' template's capabilities. + * + * @param templateId - The template ID (e.g., 'mobile', 'mobile-embedded', 'browser') + * @returns Template capability flags, or null if not found + */ +export default function getTemplateCapabilities( + templateId: string | undefined, +): ApplicationTemplate['capabilities'] | null { + if (!templateId) return null; + + const normalizedId = normalizeTemplateId(templateId); + if (!normalizedId) return null; + + const techTemplate = TechnologyBasedApplicationTemplateMetadata.find( + (metadata) => metadata.template.id === normalizedId, + ); + if (techTemplate) return techTemplate.template.capabilities ?? null; + + const platformTemplate = PlatformBasedApplicationTemplateMetadata.find( + (metadata) => metadata.template.id === normalizedId, + ); + if (platformTemplate) return platformTemplate.template.capabilities ?? null; + + return null; +} diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index 5cc9465d34..9d1223a248 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -2395,6 +2395,22 @@ 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.attestation.intro': + 'Verify the binary identity of an Android mobile client with Google Play Integrity when it initiates a flow directly.', + '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', + 'edit.advanced.attestation.placeholder.packageName': 'com.example.myapp', + 'edit.advanced.attestation.placeholder.certificateSha256Digest': 'URL-safe base64 SHA-256 digest', + 'edit.advanced.attestation.placeholder.serviceAccountCredentials': 'Paste the Google Cloud service account JSON', + 'edit.advanced.attestation.hint.packageName': + 'The Android application package name that must match the attested app.', + '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.', + '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 page -------------------- */ // Common diff --git a/tests/integration/application/application_api_test.go b/tests/integration/application/application_api_test.go index 63c3389dbd..ab6a4da15e 100644 --- a/tests/integration/application/application_api_test.go +++ b/tests/integration/application/application_api_test.go @@ -1040,6 +1040,62 @@ func (ts *ApplicationAPITestSuite) TestApplicationCreationWithPrivateKeyJWT() { } } +// TestCreateApplicationWithAttestation verifies that a mobile application's Google Play Integrity +// attestation configuration round-trips (package name and signing digests are returned), while the +// write-only service account credentials are never returned in responses. +func (ts *ApplicationAPITestSuite) TestCreateApplicationWithAttestation() { + const testClientID = "attestation_test_oauth_client" + + app := Application{ + OUID: testOUID, + Name: "Attestation Test App", + Description: "Mobile app configured with Play Integrity attestation", + AuthFlowID: defaultAuthFlowID, + RegistrationFlowID: defaultRegistrationFlowID, + IsRegistrationFlowEnabled: false, + // Attestation is a client-level setting, configured at the top level of the application + // independent of the OAuth2 protocol config. + Attestation: &AttestationConfig{ + Android: &AndroidAttestationConfig{ + PackageName: "com.example.myapp", + CertificateSha256Digests: []string{"AA:BB:CC", "DD:EE:FF"}, + ServiceAccountCredentials: `{"type":"service_account","project_id":"demo"}`, + }, + }, + InboundAuthConfig: []InboundAuthConfig{ + { + Type: "oauth2", + OAuthAppConfig: &OAuthAppConfig{ + ClientID: testClientID, + RedirectURIs: []string{"myapp://callback"}, + GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: "none", + PKCERequired: true, + PublicClient: true, + }, + }, + }, + } + + appID, err := createApplication(app) + ts.Require().NoError(err, "expected application creation to succeed") + ts.Require().NotEmpty(appID) + defer func() { + ts.Require().NoError(deleteApplication(appID), "expected application deletion to succeed") + }() + + retrieved, err := getApplicationByID(appID) + ts.Require().NoError(err, "expected application retrieval to succeed") + ts.Require().NotNil(retrieved.Attestation, "attestation config should be returned") + ts.Require().NotNil(retrieved.Attestation.Android, "android attestation config should be returned") + ts.Assert().Equal("com.example.myapp", retrieved.Attestation.Android.PackageName) + ts.Assert().Equal([]string{"AA:BB:CC", "DD:EE:FF"}, retrieved.Attestation.Android.CertificateSha256Digests) + // The write-only service account credentials must never be returned. + ts.Assert().Empty(retrieved.Attestation.Android.ServiceAccountCredentials, + "service account credentials must not be returned in GET responses") +} + // TestCreateApplicationCertLifecycle verifies that a certificate created with an // application is also removed from the database when the application is deleted. // This confirms that the cert INSERT and the app INSERT are part of the same diff --git a/tests/integration/application/model.go b/tests/integration/application/model.go index 176e135ae9..ae1ce888c9 100644 --- a/tests/integration/application/model.go +++ b/tests/integration/application/model.go @@ -36,6 +36,7 @@ type Application struct { LogoURL string `json:"logoUrl,omitempty"` Certificate *ApplicationCert `json:"certificate,omitempty"` Assertion *AssertionConfig `json:"assertion,omitempty"` + Attestation *AttestationConfig `json:"attestation,omitempty"` TosURI string `json:"tosUri,omitempty"` PolicyURI string `json:"policyUri,omitempty"` Contacts []string `json:"contacts,omitempty"` @@ -74,6 +75,18 @@ type OAuthAppConfig struct { AcrValues []string `json:"acrValues,omitempty"` } +// AttestationConfig represents the platform attestation configuration in the OAuth config. +type AttestationConfig struct { + Android *AndroidAttestationConfig `json:"android,omitempty"` +} + +// AndroidAttestationConfig represents the Google Play Integrity attestation configuration. +type AndroidAttestationConfig struct { + PackageName string `json:"packageName,omitempty"` + CertificateSha256Digests []string `json:"certificateSha256Digests,omitempty"` + ServiceAccountCredentials string `json:"serviceAccountCredentials,omitempty"` +} + // OAuthTokenConfig represents the OAuth token configuration. type OAuthTokenConfig struct { AccessToken *AccessTokenConfig `json:"accessToken,omitempty"` @@ -206,6 +219,26 @@ func (app *Application) equals(expectedApp Application) bool { return false } + // Attestation config. Service account credentials are write-only and never returned, so they + // are excluded from the comparison. + if (app.Attestation == nil) != (expectedApp.Attestation == nil) { + return false + } + if app.Attestation != nil && expectedApp.Attestation != nil { + if (app.Attestation.Android == nil) != (expectedApp.Attestation.Android == nil) { + return false + } + if app.Attestation.Android != nil && expectedApp.Attestation.Android != nil { + if app.Attestation.Android.PackageName != expectedApp.Attestation.Android.PackageName { + return false + } + if !compareStringSlices(app.Attestation.Android.CertificateSha256Digests, + expectedApp.Attestation.Android.CertificateSha256Digests) { + return false + } + } + } + // LoginConsent config if (app.LoginConsent != nil) && (expectedApp.LoginConsent != nil) { if app.LoginConsent.ValidityPeriod != expectedApp.LoginConsent.ValidityPeriod { diff --git a/tests/integration/flow/authentication/attestation_flow_test.go b/tests/integration/flow/authentication/attestation_flow_test.go new file mode 100644 index 0000000000..5a8d021efe --- /dev/null +++ b/tests/integration/flow/authentication/attestation_flow_test.go @@ -0,0 +1,264 @@ +/* + * 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. A mobile +// (public, authorization_code) application that configures Google Play Integrity attestation may +// initiate a flow directly only by presenting a valid Attestation-Token header: +// - a missing token is rejected with 401 (FES-1014); +// - a token that cannot be verified because the outbound Play Integrity call cannot complete +// (here, unusable service account credentials) surfaces as a 500 server error, not a 401 — a +// provider/configuration failure must not be reported to the client as an auth failure. +// +// A definitive token rejection (identity mismatch → 401 FES-1015) and a successful verification both +// require a genuine Google-issued Play Integrity token and a real Google Cloud service account, so +// those paths are covered by unit tests and manual/E2E verification rather than here. To keep this +// suite deterministic and offline, the application is configured with unusable service account +// credentials so the Play Integrity API call can never succeed. + +var attestationGuardOU = testutils.OrganizationUnit{ + Handle: "attestation_guard_test_ou", + Name: "Attestation Guard Test OU", + Description: "Organization unit for attestation guard flow testing", + Parent: nil, +} + +var attestationGuardUserType = testutils.UserType{ + Name: "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 attestationGuardFlow = testutils.Flow{ + Name: "Attestation Guard Auth Flow", + FlowType: "AUTHENTICATION", + Handle: "auth_flow_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", + }, + }, +} + +// attestationMobileApp is a public, redirect-based (authorization_code) mobile application that +// configures Google Play Integrity attestation. Attestation takes precedence over the redirect +// classification, so the app may initiate a flow directly — but only with a valid attestation +// token. The service account credentials are intentionally unusable so verification always fails. +var attestationMobileApp = testutils.Application{ + Name: "Play Integrity Mobile App", + Description: "Mobile application for attestation guard testing", + IsRegistrationFlowEnabled: false, + ClientID: "attestation_mobile_client", + RedirectURIs: []string{"myapp://callback"}, + AllowedUserTypes: []string{"attestation_guard_person"}, + // Attestation is a client-level setting, configured at the top level of the application + // independent of the OAuth2 protocol config. + Attestation: map[string]interface{}{ + "android": map[string]interface{}{ + "packageName": "com.example.myapp", + "certificateSha256Digests": []string{"AA:BB:CC"}, + "serviceAccountCredentials": "not-a-valid-service-account", + }, + }, + InboundAuthConfig: []map[string]interface{}{ + { + "type": "oauth2", + "config": map[string]interface{}{ + "clientId": "attestation_mobile_client", + "redirectUris": []string{"myapp://callback"}, + "grantTypes": []string{"authorization_code"}, + "responseTypes": []string{"code"}, + "tokenEndpointAuthMethod": "none", + "publicClient": true, + "pkceRequired": true, + }, + }, + }, +} + +var ( + attestationGuardOUID string + attestationGuardUserTypeID string + attestationGuardFlowID string + attestationMobileAppID string +) + +// AttestationFlowTestSuite verifies the attestation guard on direct flow initiation. +type AttestationFlowTestSuite struct { + suite.Suite + config *common.TestSuiteConfig +} + +func TestAttestationFlowTestSuite(t *testing.T) { + suite.Run(t, new(AttestationFlowTestSuite)) +} + +func (ts *AttestationFlowTestSuite) SetupSuite() { + ts.config = &common.TestSuiteConfig{} + + ouID, err := testutils.CreateOrganizationUnit(attestationGuardOU) + ts.Require().NoError(err, "failed to create OU") + attestationGuardOUID = ouID + + attestationGuardUserType.OUID = attestationGuardOUID + schemaID, err := testutils.CreateUserType(attestationGuardUserType) + ts.Require().NoError(err, "failed to create user type") + attestationGuardUserTypeID = schemaID + + flowID, err := testutils.CreateFlow(attestationGuardFlow) + ts.Require().NoError(err, "failed to create auth flow") + attestationGuardFlowID = flowID + ts.config.CreatedFlowIDs = append(ts.config.CreatedFlowIDs, flowID) + + attestationMobileApp.AuthFlowID = flowID + attestationMobileApp.OUID = attestationGuardOUID + appID, err := testutils.CreateApplication(attestationMobileApp) + ts.Require().NoError(err, "failed to create mobile application") + attestationMobileAppID = appID + + // A public / redirect-based mobile app is never issued a Flow Secret. + ts.Require().Empty(testutils.GetFlowSecret(attestationMobileAppID), + "mobile app should not be issued a Flow Secret") +} + +func (ts *AttestationFlowTestSuite) TearDownSuite() { + if attestationMobileAppID != "" { + if err := testutils.DeleteApplication(attestationMobileAppID); err != nil { + ts.T().Logf("failed to delete mobile 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 attestationGuardUserTypeID != "" { + if err := testutils.DeleteUserType(attestationGuardUserTypeID); err != nil { + ts.T().Logf("failed to delete user type: %v", err) + } + } + if attestationGuardOUID != "" { + if err := testutils.DeleteOrganizationUnit(attestationGuardOUID); 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 *AttestationFlowTestSuite) 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 +} + +// A mobile app that omits the attestation token is rejected with 401 (FES-1014). +func (ts *AttestationFlowTestSuite) TestMobileApp_MissingAttestationToken_Rejected() { + status, errResp := ts.executeNewFlowWithAttestation(map[string]interface{}{ + "applicationId": attestationMobileAppID, + "flowType": "AUTHENTICATION", + }, "") + + ts.Require().Equal(http.StatusUnauthorized, status) + ts.Require().Equal("FES-1014", errResp.Code) +} + +// A mobile app whose stored service account credentials cannot reach Google's Play Integrity API +// cannot complete verification. This is a server-side condition rather than a rejected token, so it +// surfaces as a 500 server error rather than a 401. +func (ts *AttestationFlowTestSuite) TestMobileApp_AttestationVerificationUnavailable_ServerError() { + status, errResp := ts.executeNewFlowWithAttestation(map[string]interface{}{ + "applicationId": attestationMobileAppID, + "flowType": "AUTHENTICATION", + }, "some-play-integrity-token") + + ts.Require().Equal(http.StatusInternalServerError, status) + ts.Require().Equal("SSE-5000", errResp.Code) +} diff --git a/tests/integration/testutils/api_utils.go b/tests/integration/testutils/api_utils.go index 1e74a94b53..8c2b93d4b9 100644 --- a/tests/integration/testutils/api_utils.go +++ b/tests/integration/testutils/api_utils.go @@ -423,6 +423,11 @@ func CreateApplication(app Application) (string, error) { appData["assertion"] = app.AssertionConfig } + // Add client-level attestation config if provided + if app.Attestation != nil { + appData["attestation"] = app.Attestation + } + appJSON, err := json.Marshal(appData) if err != nil { return "", fmt.Errorf("failed to marshal application: %w", err) diff --git a/tests/integration/testutils/models.go b/tests/integration/testutils/models.go index f27c4c1ed9..004a54c5db 100644 --- a/tests/integration/testutils/models.go +++ b/tests/integration/testutils/models.go @@ -58,6 +58,9 @@ type Application struct { Certificate map[string]interface{} `json:"certificate,omitempty"` InboundAuthConfig []map[string]interface{} `json:"inboundAuthConfig,omitempty"` AssertionConfig map[string]interface{} `json:"assertion,omitempty"` + // Attestation is the client-level platform attestation config, set at the top level of the + // application independent of any OAuth profile. + Attestation map[string]interface{} `json:"attestation,omitempty"` // Embedded creates a native app with no inbound OAuth profile — the canonical flow-native app // that authenticates flow initiation with a Flow Secret. When set, no default OAuth config is // synthesized.