Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Original file line number Diff line number Diff line change
@@ -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 <T> Task<T>.await(): T =
suspendCancellableCoroutine { cont ->
addOnSuccessListener { cont.resume(it) }
addOnFailureListener { cont.resumeWithException(it) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
)
Expand Down
103 changes: 103 additions & 0 deletions ios/Classes/AppAttestTokenProvider.swift
Original file line number Diff line number Diff line change
@@ -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."
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/// 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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// 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..<bytes.count).map { _ in UInt8.random(in: .min ... .max, using: &generator) })
}
return Data(bytes)
}
}
4 changes: 4 additions & 0 deletions ios/Classes/ThunderIDMethodHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,17 @@ final class ThunderIDMethodHandler {
clockTolerance: v["clockTolerance"] as? Int ?? 0
)
} ?? TokenValidationConfig()
let attestationEnabled = args["attestationEnabled"] as? Bool ?? false
return ThunderIDConfig(
baseUrl: baseUrl,
clientId: args["clientId"] as? String,
scopes: (args["scopes"] as? [String]) ?? ["openid"],
afterSignInUrl: args["afterSignInUrl"] as? String,
afterSignOutUrl: args["afterSignOutUrl"] as? String,
applicationId: args["applicationId"] as? String,
attestationEnabled: attestationEnabled,
attestationTokenProvider: attestationEnabled
? { try await AppAttestTokenProvider().requestToken() } : nil,
tokenValidation: validation,
vendor: args["vendor"] as? String ?? VendorConstants.vendorPrefix
)
Expand Down
2 changes: 1 addition & 1 deletion ios/thunderid_flutter.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Pod::Spec.new do |s|
s.source = { :path => '.' }
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'
Expand Down
12 changes: 12 additions & 0 deletions lib/src/models/thunderid_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions samples/quickstart/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
17 changes: 17 additions & 0 deletions samples/quickstart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,26 @@ flutter run -d <device-id>
| `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
Expand Down
4 changes: 4 additions & 0 deletions samples/quickstart/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
Expand Down
18 changes: 18 additions & 0 deletions test/thunderid_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading