Add Google Play Integrity attestation for mobile clients - #3951
Conversation
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
|
Caution Review failedAn error occurred during the review process. Please try again later. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
d99e269 to
4826108
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
backend/internal/application/service_test.go (1)
1608-1682: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winGood coverage for create+encrypt+strip; consider adding update-preserve and lookup-error cases.
This test validates the create path well. Given the error-handling gap flagged in
encryptAttestationCredentialsForPersist(service.go, Lines 1659-1695), adding a test forUpdateApplicationwith omitted credentials — including a case whereGetInboundClientByEntityIDreturns a non-"not found" error — would catch regressions on the preserve-on-update contract.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/internal/application/service_test.go` around lines 1608 - 1682, The current coverage only verifies credential handling during creation; extend the attestation tests to cover UpdateApplication when credentials are omitted. Add a successful update case that preserves existing encrypted credentials, and a GetInboundClientByEntityID non-“not found” error case that propagates the error instead of silently continuing, targeting encryptAttestationCredentialsForPersist and the UpdateApplication flow.api/application.yaml (1)
815-816: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
serviceAccountCredentialsisn't markedwriteOnlyand is reused verbatim across request/response schemas.The prose says the field is "Write-only: never returned in responses," but the same
AndroidAttestationschema (viaAttestation) is$ref'd fromApplicationRequest,ApplicationCompleteResponse, andApplicationGetResponsealike, withoutwriteOnly: true. This contradicts the field's own documentation and diverges from this file's existing convention —flowSecretis deliberately omitted fromApplicationGetResponseand duplicated as a separate response-only definition inApplicationCompleteResponserather than sharing one schema. Generated client SDKs/docs (Scalar) will showserviceAccountCredentialsas present on GET/PUT/POST responses even though the backend strips it.📝 Proposed fix: mark the field writeOnly
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.Also applies to: 945-946, 1059-1060, 1340-1370
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@api/application.yaml` around lines 815 - 816, Update the serviceAccountCredentials property in the AndroidAttestation schema to set its OpenAPI writeOnly attribute to true, preserving the existing documentation and schema references. Ensure this metadata applies wherever Attestation is reused by ApplicationRequest, ApplicationCompleteResponse, and ApplicationGetResponse so generated clients do not expose the field in responses.frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx (1)
141-163: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winDigest fields lack individually accessible names.
FormLabel htmlFor="attestation-digests-section"targets a<Stack>(a div), not a form control, and each digestTextField(line 154) has no distinguishing accessible name for screen reader users navigating the repeatable list.♿ Proposed fix
- <FormControl fullWidth> - <FormLabel htmlFor="attestation-digests-section"> + <FormControl fullWidth> + <FormLabel id="attestation-digests-label"> {t('applications:edit.advanced.attestation.labels.certificateSha256Digests')} </FormLabel> @@ <TextField fullWidth id={`attestation-digest-${index}-input`} + aria-label={`${t('applications:edit.advanced.attestation.labels.certificateSha256Digests')} ${index + 1}`} value={digest}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx` around lines 141 - 163, Update the mapped digest TextField in the digests section to provide a unique accessible name for each repeated field, using the digest index or equivalent position in its label/aria-label. Do not rely on FormLabel htmlFor pointing to the surrounding Stack; preserve the existing digest editing behavior and identifiers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/internal/application/service.go`:
- Around line 1659-1695: Update encryptAttestationCredentialsForPersist so a
failed GetInboundClientByEntityID lookup distinguishes a not-found result from
other errors; preserve the existing credential only when the record is found,
treat not-found as having no value to preserve, and return an internal error for
any other lookup failure so omitted credentials cannot overwrite stored data.
In `@backend/internal/attestation/playintegrity.go`:
- Around line 77-85: Update the attestation validation around the package-name
and certificate checks in the relevant validation function so configured
applications must provide both a package name and at least one registered
certificate digest. Reject incomplete configuration before accepting the
PLAY_RECOGNIZED verdict, and require the attested package and certificate digest
to match their configured identities.
In `@backend/internal/flow/flowexec/service.go`:
- Around line 273-295: Update verifyAttestation and the attestationVsvc.Verify
call to distinguish definitive invalid-token responses from verifier outages,
timeouts, and other transient failures; return ErrorAttestationInvalid only for
the former and the appropriate retriable server error for the latter. Create or
enforce a bounded request context with a deadline for this outbound verification
call, preserving cancellation and existing logging.
In `@docs/content/guides/guides/applications.mdx`:
- Line 196: Split the overlong sentence in the attestation-token documentation
into shorter sentences while preserving the existing 401 rejection conditions,
continuation-request exception, and Flow Execution API Reference link. Keep the
wording clear and ensure each resulting sentence stays within Vale’s 35-word
limit.
In
`@frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx`:
- Around line 64-69: Update the AttestationSection state synchronization around
packageName, digests, and credentials so local values refresh when the
attestation prop changes, including when Android configuration is replaced or
cleared. Add an effect keyed to the relevant attestation data that updates
packageName and digests from the current Android config and resets write-only
credentials appropriately, while preserving user edits until the prop changes.
In
`@frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx`:
- Around line 95-103: Update currentAttestation in the edit-application advanced
settings flow to distinguish an explicitly cleared null from an undefined,
not-yet-edited value: use editedApp.attestation whenever it has been set,
including null, and fall back to application.attestation only when it is
undefined. Keep handleAttestationChange and the existing save behavior
unchanged.
---
Nitpick comments:
In `@api/application.yaml`:
- Around line 815-816: Update the serviceAccountCredentials property in the
AndroidAttestation schema to set its OpenAPI writeOnly attribute to true,
preserving the existing documentation and schema references. Ensure this
metadata applies wherever Attestation is reused by ApplicationRequest,
ApplicationCompleteResponse, and ApplicationGetResponse so generated clients do
not expose the field in responses.
In `@backend/internal/application/service_test.go`:
- Around line 1608-1682: The current coverage only verifies credential handling
during creation; extend the attestation tests to cover UpdateApplication when
credentials are omitted. Add a successful update case that preserves existing
encrypted credentials, and a GetInboundClientByEntityID non-“not found” error
case that propagates the error instead of silently continuing, targeting
encryptAttestationCredentialsForPersist and the UpdateApplication flow.
In
`@frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx`:
- Around line 141-163: Update the mapped digest TextField in the digests section
to provide a unique accessible name for each repeated field, using the digest
index or equivalent position in its label/aria-label. Do not rely on FormLabel
htmlFor pointing to the surrounding Stack; preserve the existing digest editing
behavior and identifiers.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: d6431ea3-01aa-498b-a53c-aba76c952b73
⛔ Files ignored due to path filters (3)
backend/go.sumis excluded by!**/*.sumbackend/tests/mocks/flow/flowexecmock/FlowExecServiceInterface_mock.gois excluded by!**/*_mock.gobackend/tests/mocks/flow/flowexecmock/attestationVerifier_mock.gois excluded by!**/*_mock.go
📒 Files selected for processing (43)
api/application.yamlapi/flow-execution.yamlbackend/.mockery.private.ymlbackend/cmd/server/servicemanager.gobackend/go.modbackend/internal/application/handler.gobackend/internal/application/init.gobackend/internal/application/init_test.gobackend/internal/application/service.gobackend/internal/application/service_test.gobackend/internal/attestation/VerifierInterface_mock_test.gobackend/internal/attestation/errors.gobackend/internal/attestation/integrityTokenDecoder_mock_test.gobackend/internal/attestation/interface.gobackend/internal/attestation/playintegrity.gobackend/internal/attestation/playintegrity_test.gobackend/internal/flow/flowexec/FlowExecServiceInterface_mock_test.gobackend/internal/flow/flowexec/attestationVerifier_mock_test.gobackend/internal/flow/flowexec/constants.gobackend/internal/flow/flowexec/error_constants.gobackend/internal/flow/flowexec/handler.gobackend/internal/flow/flowexec/handler_test.gobackend/internal/flow/flowexec/init.gobackend/internal/flow/flowexec/interface.gobackend/internal/flow/flowexec/service.gobackend/internal/flow/flowexec/service_test.gobackend/internal/inboundclient/store.gobackend/internal/system/constants/server_constants.gobackend/internal/system/i18n/core/defaults.gobackend/pkg/thunderidengine/providers/model.godocs/content/guides/guides/applications.mdxfrontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsxfrontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsxfrontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.roundtrip.test.tsxfrontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsxfrontend/apps/console/src/features/applications/models/application.tsfrontend/apps/console/src/features/applications/models/oauth.tsfrontend/packages/i18n/src/locales/en-US.tstests/integration/application/application_api_test.gotests/integration/application/model.gotests/integration/flow/authentication/attestation_flow_test.gotests/integration/testutils/api_utils.gotests/integration/testutils/models.go
4826108 to
131a347
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
131a347 to
13d00ac
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
13d00ac to
216b9ee
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
216b9ee to
b1acec7
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
b1acec7 to
a136739
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
a136739 to
838187f
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
838187f to
f568300
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net Next Steps
|
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: google.golang.org/api Dependency name: golang.org/x/net |
f1ea237 to
3f2dcb4
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: github.com/cloudflare/circl Dependency name: google.golang.org/api Dependency name: golang.org/x/net |
| // Integrity API. | ||
| func newPlayIntegrityVerifier(cryptoSvc kmprovider.RuntimeCryptoProvider) providers.AttestationProvider { | ||
| return &playIntegrityVerifier{ | ||
| decoder: &googlePlayIntegrityDecoder{}, |
There was a problem hiding this comment.
Rather than following this pattern, shall we stick to project's guidelines?
- Define a private constructor method starting with
new... - Wire it as a dependency via the package's init.go. No need to pass it from service manager
There was a problem hiding this comment.
Add newGooglePlayIntegrityDecoder method and wire it from attestation/init.go
| 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 |
There was a problem hiding this comment.
This is something not introduced by this PR right? Maybe missed to run go mod tidy in a previous PR?
cc: @hwupathum
There was a problem hiding this comment.
No these are not from me.
3f2dcb4 to
af26c78
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: github.com/cloudflare/circl Dependency name: google.golang.org/api Dependency name: golang.org/x/net |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
backend/internal/attestation/decoder.go (1)
42-63: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy liftRecreating the API client on every request prevents connection pooling.
Creating a new
*playintegrity.Service(which internally instantiates a newhttp.Clientandhttp.Transport) on everyDecodecall prevents TCP connection pooling and TLS session resumption. This will cause significant latency and resource exhaustion under load.Consider caching the
*playintegrity.Service(or a configuredhttp.Client) keyed by a hash of the credentials to reuse connections across requests. When caching, usecontext.Background()forNewServiceand continue passing the request context to.Context(ctx).Do().Additionally, fail fast if
credentialsJSONis empty to preventcredentials.DetectDefaultfrom falling back to the server's Application Default Credentials (ADC).⚡ Proposed fix with client caching
@@ -21,6 +21,8 @@ import ( "context" + "crypto/sha256" "fmt" + "sync" "cloud.google.com/go/auth/credentials" @@ -36,8 +38,10 @@ // googlePlayIntegrityDecoder decodes tokens by calling the Google Play Integrity API using the // application's service account credentials. -type googlePlayIntegrityDecoder struct{} +type googlePlayIntegrityDecoder struct { + cache sync.Map +} // 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) { + if credentialsJSON == "" { + return nil, fmt.Errorf("empty play integrity credentials") + } + + hash := sha256.Sum256([]byte(credentialsJSON)) + cacheKey := fmt.Sprintf("%x", hash) + + var svc *playintegrity.Service + if cached, ok := d.cache.Load(cacheKey); ok { + svc = cached.(*playintegrity.Service) + } else { - 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) - } + 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) + } + + // Use context.Background() for client creation to ensure connection pooling across requests + newSvc, err := playintegrity.NewService(context.Background(), option.WithAuthCredentials(creds)) + if err != nil { + return nil, fmt.Errorf("failed to create play integrity client: %w", err) + } + + d.cache.Store(cacheKey, newSvc) + svc = newSvc + } resp, err := svc.V1.DecodeIntegrityToken(packageName,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/internal/attestation/decoder.go` around lines 42 - 63, Update googlePlayIntegrityDecoder.Decode to reject empty credentialsJSON before calling credentials.DetectDefault, and reuse a cached playintegrity.Service keyed by the credentials rather than constructing one per request. Initialize cached services with context.Background(), while continuing to pass the request ctx to DecodeIntegrityToken(...).Context(ctx).Do(); preserve the existing credential, client-creation, and decode error wrapping.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@backend/internal/attestation/decoder.go`:
- Around line 42-63: Update googlePlayIntegrityDecoder.Decode to reject empty
credentialsJSON before calling credentials.DetectDefault, and reuse a cached
playintegrity.Service keyed by the credentials rather than constructing one per
request. Initialize cached services with context.Background(), while continuing
to pass the request ctx to DecodeIntegrityToken(...).Context(ctx).Do(); preserve
the existing credential, client-creation, and decode error wrapping.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e48d1319-283a-46a9-b37f-20286927bd81
⛔ Files ignored due to path filters (4)
backend/go.sumis excluded by!**/*.sumbackend/tests/mocks/attestationprovidermock/AttestationProvider_mock.gois excluded by!**/*_mock.gobackend/tests/mocks/flow/flowexecmock/FlowExecServiceInterface_mock.gois excluded by!**/*_mock.gobackend/tests/mocks/flow/flowexecmock/attestationVerifier_mock.gois excluded by!**/*_mock.go
📒 Files selected for processing (32)
api/application.yamlapi/flow-execution.yamlbackend/.mockery.private.ymlbackend/.mockery.public.ymlbackend/cmd/server/servicemanager.gobackend/go.modbackend/internal/application/handler.gobackend/internal/application/init.gobackend/internal/application/init_test.gobackend/internal/application/service.gobackend/internal/application/service_test.gobackend/internal/attestation/decoder.gobackend/internal/attestation/error_constants.gobackend/internal/attestation/init.gobackend/internal/attestation/integrityTokenDecoder_mock_test.gobackend/internal/attestation/play_integrity.gobackend/internal/attestation/play_integrity_test.gobackend/internal/flow/flowexec/FlowExecServiceInterface_mock_test.gobackend/internal/flow/flowexec/constants.gobackend/internal/flow/flowexec/error_constants.gobackend/internal/flow/flowexec/handler.gobackend/internal/flow/flowexec/handler_test.gobackend/internal/flow/flowexec/init.gobackend/internal/flow/flowexec/interface.gobackend/internal/flow/flowexec/service.gobackend/internal/flow/flowexec/service_test.gobackend/internal/inboundclient/store.gobackend/internal/system/constants/server_constants.gobackend/internal/system/i18n/core/defaults.gobackend/pkg/thunderidengine/engine.gobackend/pkg/thunderidengine/providers/interface.gobackend/pkg/thunderidengine/providers/model.go
🚧 Files skipped from review as they are similar to previous changes (25)
- backend/internal/system/constants/server_constants.go
- api/flow-execution.yaml
- backend/internal/flow/flowexec/error_constants.go
- backend/internal/system/i18n/core/defaults.go
- backend/.mockery.private.yml
- backend/internal/application/init_test.go
- backend/internal/flow/flowexec/constants.go
- backend/internal/flow/flowexec/interface.go
- backend/internal/attestation/init.go
- backend/.mockery.public.yml
- backend/pkg/thunderidengine/providers/interface.go
- backend/pkg/thunderidengine/providers/model.go
- backend/internal/application/handler.go
- backend/internal/flow/flowexec/handler_test.go
- backend/internal/application/init.go
- backend/internal/attestation/play_integrity.go
- backend/internal/inboundclient/store.go
- api/application.yaml
- backend/internal/attestation/play_integrity_test.go
- backend/internal/flow/flowexec/FlowExecServiceInterface_mock_test.go
- backend/internal/attestation/integrityTokenDecoder_mock_test.go
- backend/go.mod
- backend/internal/flow/flowexec/service.go
- backend/internal/application/service.go
- backend/internal/flow/flowexec/service_test.go
af26c78 to
1a0fbd0
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: github.com/cloudflare/circl Dependency name: google.golang.org/api Dependency name: golang.org/x/net |
1a0fbd0 to
3405887
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: github.com/cloudflare/circl Dependency name: google.golang.org/api Dependency name: golang.org/x/net |
| flowProvider, graphBuilder) | ||
| flowExecService := newFlowExecService(flowProvider, flowStore, flowEngine, | ||
| actorProvider, observabilitySvc, transactioner, cryptoSvc, graphBuilder, cfg) | ||
| actorProvider, observabilitySvc, transactioner, cryptoSvc, attestation.Initialize(cryptoSvc), |
There was a problem hiding this comment.
Attestation should be passed from service manager. This is not the correct pattern
| // Integrity API. | ||
| func newPlayIntegrityVerifier(cryptoSvc kmprovider.RuntimeCryptoProvider) providers.AttestationProvider { | ||
| return &playIntegrityVerifier{ | ||
| decoder: &googlePlayIntegrityDecoder{}, |
There was a problem hiding this comment.
Add newGooglePlayIntegrityDecoder method and wire it from attestation/init.go
3405887 to
21b7c85
Compare
Dependency Validation ResultsDependency name: cloud.google.com/go/auth Dependency name: github.com/cloudflare/circl Dependency name: google.golang.org/api Dependency name: golang.org/x/net |
Purpose
Mobile applications cannot safely hold a Flow Secret, so they currently cannot initiate an authentication flow directly via
POST /flow/execute— a publicauthorization_codeapp is classified as not permitted to initiate directly, and embedded apps must present a Flow Secret. This PR lets an application prove its binary identity at flow initiation instead, using Google Play Integrity attestation.When an application configures attestation, it initiates a new authentication flow by presenting a valid Play Integrity token in an
Attestation-Tokenrequest header. ThunderID verifies the token against Google's Play Integrity API and confirms the attested app matches the registered package name and signing certificate before starting the flow.Approach
Attestation is a client-level identity check, not an OAuth2 setting. It is modeled on the inbound client (
providers.InboundClient), independent of any protocol profile, so it applies to any application type — embedded or OAuth2 — and is persisted in the existingINBOUND_CLIENT.PROPERTIESJSON column, requiring no schema change. In the API and console it is a top-levelattestationfield on the application.Enforcement at flow initiation (
flowexec).resolveFlowInitiationModeresolves the inbound client first. If attestation is configured it returnsflowInitiationAttestation, which takes precedence over the protocol-based classification and is the only credential checked — any Flow Secret is ignored. The guard runs solely on new-flow initiation of anAUTHENTICATIONflow; flow continuation (requests carrying anexecutionId) and other flow types are unaffected.401 FES-1014(ErrorAttestationRequired)401 FES-1015(ErrorAttestationInvalid)Verification (
internal/attestation). AVerifierInterfacedecodes the token via Google's server-sidedecodeIntegrityTokenAPI (google.golang.org/api/playintegrity/v1, authenticated with the app's service-account credentials viacloud.google.com/go/auth). The Google call sits behind a small internal seam so it is fully mocked in unit tests — no live calls in CI. Verification confirms the attestedpackageName, an intersection of the configured signing-certificate SHA-256 digests, and anappRecognitionVerdictofPLAY_RECOGNIZED. The verifier is behind an interface so Apple App Attest can be added later without touching the flow layer.Write-only credentials. The Google service-account JSON is encrypted at rest with AES-GCM (via the runtime crypto provider), never returned in any GET/list/echo response, and preserved when omitted on update.
Console. The application's Advanced tab exposes an Attestation section (package name, signing-certificate digests, service-account credentials); the value is stored at the application level. The section is gated by a declarative per-template capability flag (
capabilities.attestationin the template file), so it appears only for templates that support it — the mobile and wallet templates (covering their embedded variants via id normalization).Key design notes / out of scope (follow-ups): nonce/challenge replay-binding is not yet implemented; Apple App Attest plugs into the same verifier interface later. Note that once attestation is configured there is no Flow-Secret fallback — a misconfigured attestation blocks initiation by design.
UI Preview
Related Issues
Related PRs
Checklist
docs/content/guides/guides/applications.mdx)internal/attestation,flowexec,application)tests/integration/flow/authentication/attestation_flow_test.go,tests/integration/application)breaking changelabel added.Security checks
Summary by CodeRabbit
Attestation-Tokenwhen starting authentication flows.