From 3ee54d88e7543cfe51121a3d3f7e0d7bbe16e043 Mon Sep 17 00:00:00 2001 From: Malith-19 Date: Wed, 22 Jul 2026 13:29:59 +0530 Subject: [PATCH] feat: support platform attestation in SDK and sample --- android/build.gradle | 12 +- .../flutter/PlayIntegrityTokenProvider.kt | 59 ++++++++++ .../flutter/ThunderIDMethodHandler.kt | 14 +++ ios/Classes/AppAttestTokenProvider.swift | 103 ++++++++++++++++++ ios/Classes/ThunderIDMethodHandler.swift | 4 + ios/thunderid_flutter.podspec | 2 +- lib/src/models/thunderid_config.dart | 12 ++ samples/quickstart/.env.example | 2 + samples/quickstart/README.md | 17 +++ samples/quickstart/lib/main.dart | 4 + test/thunderid_client_test.dart | 18 +++ 11 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 android/src/main/kotlin/dev/thunderid/flutter/PlayIntegrityTokenProvider.kt create mode 100644 ios/Classes/AppAttestTokenProvider.swift diff --git a/android/build.gradle b/android/build.gradle index e72bf5c..ef91530 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -62,13 +62,9 @@ def flutterEngineVersion = new File(flutterSdkPath(), 'bin/cache/engine.stamp'). dependencies { compileOnly "io.flutter:flutter_embedding_debug:1.0.0-$flutterEngineVersion" - // Pinned to the merge commit of https://github.com/thunder-id/android-sdks/pull/12 (fixed a - // `publishing` block that never attached the compiled AAR to its Maven publication, which had - // made every ref/commit/tag resolve to an empty POM shell via JitPack). Pinned to a commit - // (not "main-SNAPSHOT"/a branch) so JitPack's build cache is permanent and CI doesn't hit the - // branch-snapshot cold-start 404 on every new push. Uses the full 40-char SHA — JitPack's - // short-SHA cache entry for this commit got stuck on a transient build infra error, so the - // full SHA is used here as a distinct, successfully-built cache key. - implementation 'com.github.thunder-id:android-sdks:b069ba42c8685a578506c4162b19716378659481' + // Released tag on JitPack — immutable, so the build cache is permanent. + implementation 'com.github.thunder-id:android-sdks:v0.2.0' + // Google Play Integrity — used by PlayIntegrityTokenProvider for platform attestation. + implementation 'com.google.android.play:integrity:1.6.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' } diff --git a/android/src/main/kotlin/dev/thunderid/flutter/PlayIntegrityTokenProvider.kt b/android/src/main/kotlin/dev/thunderid/flutter/PlayIntegrityTokenProvider.kt new file mode 100644 index 0000000..01dabdb --- /dev/null +++ b/android/src/main/kotlin/dev/thunderid/flutter/PlayIntegrityTokenProvider.kt @@ -0,0 +1,59 @@ +/* + * 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 dev.thunderid.flutter + +import android.content.Context +import com.google.android.gms.tasks.Task +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Mints Google Play Integrity tokens for the native SDK's attestationTokenProvider. + * The warm-up token provider is cached and reused across requests. + */ +class PlayIntegrityTokenProvider( + private val context: Context, + private val cloudProjectNumber: Long, +) { + private var tokenProvider: StandardIntegrityTokenProvider? = null + + suspend fun requestToken(): String { + val provider = tokenProvider ?: prepareTokenProvider().also { tokenProvider = it } + return provider.request(StandardIntegrityTokenRequest.builder().build()).await().token() + } + + private suspend fun prepareTokenProvider(): StandardIntegrityTokenProvider = + IntegrityManagerFactory.createStandard(context) + .prepareIntegrityToken( + PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(cloudProjectNumber) + .build(), + ).await() +} + +private suspend fun Task.await(): T = + suspendCancellableCoroutine { cont -> + addOnSuccessListener { cont.resume(it) } + addOnFailureListener { cont.resumeWithException(it) } + } diff --git a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt index 9afdd5e..dc3ed83 100644 --- a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt +++ b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt @@ -147,6 +147,14 @@ class ThunderIDMethodHandler(private val context: Context) { validateIssuer = validationMap?.get("validateIssuer") as? Boolean ?: true, clockTolerance = validationMap?.get("clockTolerance") as? Int ?: 0 ) + val attestationEnabled = args["attestationEnabled"] as? Boolean ?: false + val cloudProjectNumber = (args["cloudProjectNumber"] as? Number)?.toLong() + if (attestationEnabled && (cloudProjectNumber == null || cloudProjectNumber <= 0L)) { + throw IAMException( + ThunderIDErrorCode.INVALID_CONFIGURATION, + "cloudProjectNumber must be a positive number when attestationEnabled is true" + ) + } @Suppress("UNCHECKED_CAST") return ThunderIDConfig( baseUrl = baseUrl, @@ -155,6 +163,12 @@ class ThunderIDMethodHandler(private val context: Context) { afterSignInUrl = args["afterSignInUrl"] as? String, afterSignOutUrl = args["afterSignOutUrl"] as? String, applicationId = args["applicationId"] as? String, + attestationEnabled = attestationEnabled, + attestationTokenProvider = if (attestationEnabled) { + PlayIntegrityTokenProvider(context, cloudProjectNumber!!)::requestToken + } else { + null + }, tokenValidation = validation, vendor = args["vendor"] as? String ?: ThunderIDConfig.DEFAULT_VENDOR ) diff --git a/ios/Classes/AppAttestTokenProvider.swift b/ios/Classes/AppAttestTokenProvider.swift new file mode 100644 index 0000000..7df123e --- /dev/null +++ b/ios/Classes/AppAttestTokenProvider.swift @@ -0,0 +1,103 @@ +/* + * 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 CryptoKit +import DeviceCheck +import Foundation + +/// Errors surfaced by ``AppAttestTokenProvider``. +enum AppAttestError: LocalizedError { + case unsupported + + var errorDescription: String? { + switch self { + case .unsupported: + return "App Attest is unavailable here — it requires a physical device with a Secure Enclave." + } + } +} + +/// Mints Apple App Attest tokens for `ThunderIDConfig.attestationTokenProvider`. +/// Requires a physical device and the App Attest entitlement. The challenge should +/// come from the server in production; this sample generates it locally. +final class AppAttestTokenProvider { + private let service = DCAppAttestService.shared + private let keyIdStorageKey: String + private let attestedStorageKey: String + + init(keyPrefix: String = "dev.thunderid.flutter.appAttest") { + keyIdStorageKey = "\(keyPrefix).keyId" + attestedStorageKey = "\(keyPrefix).attested" + } + + /// Returns a base64-encoded attestation/assertion envelope. + func requestToken() async throws -> String { + guard service.isSupported else { throw AppAttestError.unsupported } + let keyId = try await resolveKeyId() + let challenge = makeChallenge() + let clientDataHash = Data(SHA256.hash(data: challenge)) + let envelope = try await buildEnvelope(keyId: keyId, challenge: challenge, clientDataHash: clientDataHash) + let data = try JSONSerialization.data(withJSONObject: envelope) + return data.base64EncodedString() + } + + /// Returns the stored key id, generating one on first use. + private func resolveKeyId() async throws -> String { + if let existing = UserDefaults.standard.string(forKey: keyIdStorageKey) { + return existing + } + let keyId = try await service.generateKey() + UserDefaults.standard.set(keyId, forKey: keyIdStorageKey) + return keyId + } + + /// Attests the key on first use, then asserts on later calls. + private func buildEnvelope( + keyId: String, + challenge: Data, + clientDataHash: Data + ) async throws -> [String: String] { + var envelope: [String: String] = [ + "platform": "ios", + "keyId": keyId, + "challenge": challenge.base64EncodedString() + ] + if UserDefaults.standard.bool(forKey: attestedStorageKey) { + let assertion = try await service.generateAssertion(keyId, clientDataHash: clientDataHash) + envelope["type"] = "assertion" + envelope["assertion"] = assertion.base64EncodedString() + } else { + let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash) + UserDefaults.standard.set(true, forKey: attestedStorageKey) + envelope["type"] = "attestation" + envelope["attestation"] = attestation.base64EncodedString() + } + return envelope + } + + /// Generates a random 32-byte challenge. + private func makeChallenge() -> Data { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + var generator = SystemRandomNumberGenerator() + return Data((0.. '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.dependency 'ThunderID', '~> 0.0' + s.dependency 'ThunderID', '~> 0.2.0' s.platform = :ios, '16.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'SWIFT_STRICT_CONCURRENCY' => 'complete' } s.swift_version = '5.9' diff --git a/lib/src/models/thunderid_config.dart b/lib/src/models/thunderid_config.dart index f77c25c..ac83ee5 100644 --- a/lib/src/models/thunderid_config.dart +++ b/lib/src/models/thunderid_config.dart @@ -43,6 +43,14 @@ class ThunderIDConfig { final String? applicationId; final String? organizationHandle; + // Platform Attestation + /// When true, the native SDK sends a platform attestation token (Apple App Attest / + /// Google Play Integrity) on native flow-initiate requests. + final bool attestationEnabled; + + /// Google Cloud project number, required by Play Integrity on Android. + final int? cloudProjectNumber; + // Token Validation final TokenValidationConfig tokenValidation; @@ -67,6 +75,8 @@ class ThunderIDConfig { this.signUpOptions = const {}, this.applicationId, this.organizationHandle, + this.attestationEnabled = false, + this.cloudProjectNumber, this.tokenValidation = const TokenValidationConfig(), this.preferences, this.vendor = defaultVendor, @@ -85,6 +95,8 @@ class ThunderIDConfig { 'signUpOptions': signUpOptions, if (applicationId != null) 'applicationId': applicationId, if (organizationHandle != null) 'organizationHandle': organizationHandle, + 'attestationEnabled': attestationEnabled, + if (cloudProjectNumber != null) 'cloudProjectNumber': cloudProjectNumber, 'tokenValidation': tokenValidation.toMap(), if (preferences != null) 'preferences': preferences!.toMap(), 'vendor': vendor, diff --git a/samples/quickstart/.env.example b/samples/quickstart/.env.example index f43213b..891538c 100644 --- a/samples/quickstart/.env.example +++ b/samples/quickstart/.env.example @@ -3,3 +3,5 @@ THUNDERID_CLIENT_ID=your-client-id THUNDERID_APP_ID=your-application-id THUNDERID_AFTER_SIGN_IN_URL=dev.thunderid.quickstart://callback THUNDERID_AFTER_SIGN_OUT_URL=dev.thunderid.quickstart://logout +THUNDERID_ATTESTATION_ENABLED=false +THUNDERID_CLOUD_PROJECT_NUMBER= diff --git a/samples/quickstart/README.md b/samples/quickstart/README.md index 8de2b64..237636f 100644 --- a/samples/quickstart/README.md +++ b/samples/quickstart/README.md @@ -60,9 +60,26 @@ flutter run -d | `THUNDERID_APP_ID` | Application UUID from ThunderID console | | `THUNDERID_AFTER_SIGN_IN_URL` | Custom URL scheme callback (e.g. `dev.thunderid.app://callback`) | | `THUNDERID_AFTER_SIGN_OUT_URL` | Post-logout redirect URI | +| `THUNDERID_ATTESTATION_ENABLED` | `true` to send a platform attestation token on native flows | +| `THUNDERID_CLOUD_PROJECT_NUMBER` | Google Cloud project number (Android Play Integrity) | > `.env` is gitignored. Never commit real credentials. +### Platform attestation (optional) + +Set `THUNDERID_ATTESTATION_ENABLED=true` to send a platform attestation token on native +sign-in/sign-up. The token is minted natively by the plugin — Apple App Attest on iOS, +Google Play Integrity on Android. + +Requirements to test end-to-end: +- **Android**: set `THUNDERID_CLOUD_PROJECT_NUMBER`, and publish the app to a Play Console + track linked to that Cloud project (Play Integrity needs a recognized package + signature). +- **iOS**: a physical device and the **App Attest** capability on the `Runner` target (adds the + `com.apple.developer.devicecheck.appattest-environment` entitlement). App Attest does not work + in the simulator. +- **Server**: must issue the challenge and verify the attestation. The sample generates the + challenge locally to exercise the flow, which will not pass server-side freshness checks. + --- ## Running tests diff --git a/samples/quickstart/lib/main.dart b/samples/quickstart/lib/main.dart index 7846a11..f349a70 100644 --- a/samples/quickstart/lib/main.dart +++ b/samples/quickstart/lib/main.dart @@ -33,6 +33,10 @@ void main() async { afterSignInUrl: dotenv.env['THUNDERID_AFTER_SIGN_IN_URL'], afterSignOutUrl: dotenv.env['THUNDERID_AFTER_SIGN_OUT_URL'], scopes: const ['openid', 'profile', 'email'], + attestationEnabled: + dotenv.env['THUNDERID_ATTESTATION_ENABLED']?.toLowerCase() == 'true', + cloudProjectNumber: + int.tryParse(dotenv.env['THUNDERID_CLOUD_PROJECT_NUMBER'] ?? ''), ), child: const QuickstartApp(), ), diff --git a/test/thunderid_client_test.dart b/test/thunderid_client_test.dart index 160a0c4..2d94434 100644 --- a/test/thunderid_client_test.dart +++ b/test/thunderid_client_test.dart @@ -140,6 +140,24 @@ void main() { expect(map.containsKey('clientId'), false); expect(map.containsKey('afterSignInUrl'), false); }); + + test('attestation defaults to disabled', () { + const config = ThunderIDConfig(baseUrl: 'https://localhost:8090'); + final map = config.toMap(); + expect(map['attestationEnabled'], false); + expect(map.containsKey('cloudProjectNumber'), false); + }); + + test('toMap includes attestation fields when set', () { + const config = ThunderIDConfig( + baseUrl: 'https://localhost:8090', + attestationEnabled: true, + cloudProjectNumber: 123456789, + ); + final map = config.toMap(); + expect(map['attestationEnabled'], true); + expect(map['cloudProjectNumber'], 123456789); + }); }); group('sign-out', () {