Skip to content
Merged
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
9 changes: 8 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ def flutterEngineVersion = new File(flutterSdkPath(), 'bin/cache/engine.stamp').

dependencies {
compileOnly "io.flutter:flutter_embedding_debug:1.0.0-$flutterEngineVersion"
implementation 'com.github.thunder-id:android-sdks:v0.1.0'
// 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'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package dev.thunderid.flutter

import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import dev.thunderid.android.auth.FederatedAuthSession
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
Expand All @@ -9,10 +16,27 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class ThunderIDFlutterPlugin : FlutterPlugin, MethodCallHandler {
class ThunderIDFlutterPlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var handler: ThunderIDMethodHandler
private val scope = CoroutineScope(Dispatchers.Main)
private var activity: Activity? = null

// Cancels any in-flight federated-auth Custom Tab session when the host Activity resumes
// without having received the provider's redirect (e.g. the user pressed back in the tab).
// Safe to call unconditionally: FederatedAuthSession.cancelIfPending() is a no-op once the
// session already completed via onNewIntent.
private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityResumed(resumedActivity: Activity) {
if (resumedActivity === activity) FederatedAuthSession.cancelIfPending()
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
handler = ThunderIDMethodHandler(binding.applicationContext)
Expand All @@ -30,4 +54,48 @@ class ThunderIDFlutterPlugin : FlutterPlugin, MethodCallHandler {
handler.handle(call.method, args, result)
}
}

// ── ActivityAware — required so `continueFederatedAuth` can launch a Custom Tab from an
// Activity context, and so the provider's redirect (delivered via onNewIntent to the host
// app's registered callback scheme) reaches FederatedAuthSession.onRedirect ──

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
attachActivity(binding)
}

override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
attachActivity(binding)
}

override fun onDetachedFromActivityForConfigChanges() {
detachActivity()
}

override fun onDetachedFromActivity() {
// Unlike onDetachedFromActivityForConfigChanges, this is a permanent detach (e.g. the
// host Activity finished while the Custom Tab was open) — nothing will resume to call
// cancelIfPending() via onActivityResumed, so the suspended method-channel request would
// otherwise hang indefinitely.
FederatedAuthSession.cancelIfPending()
detachActivity()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private fun attachActivity(binding: ActivityPluginBinding) {
activity = binding.activity
handler.activity = binding.activity
binding.activity.application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
binding.addOnNewIntentListener { intent -> handleNewIntent(intent) }
}

private fun detachActivity() {
activity?.application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
activity = null
handler.activity = null
}

private fun handleNewIntent(intent: Intent): Boolean {
val data = intent.data
if (data != null) FederatedAuthSession.onRedirect(data)
return false
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package dev.thunderid.flutter

import android.app.Activity
import android.content.Context
import io.flutter.plugin.common.MethodChannel.Result
import dev.thunderid.android.*
import dev.thunderid.android.auth.PKCEManager
import dev.thunderid.android.auth.FederatedAuthSession
import kotlinx.coroutines.CancellationException

/**
* Routes Flutter method channel calls to the native Android ThunderIDClient (spec §7.1).
Expand All @@ -12,6 +14,13 @@ import dev.thunderid.android.auth.PKCEManager
class ThunderIDMethodHandler(private val context: Context) {
private val client = ThunderIDClient()

/**
* Set by [ThunderIDFlutterPlugin] from its `ActivityAware` callbacks. Required for
* `continueFederatedAuth` since launching a Custom Tab needs an Activity context; falls back
* to the application context (set at construction) if no Activity is currently attached.
*/
var activity: Activity? = null

suspend fun handle(method: String, args: Map<String, Any?>, result: Result) {
try {
when (method) {
Expand All @@ -34,6 +43,28 @@ class ThunderIDMethodHandler(private val context: Context) {
val response = client.signIn(buildPayload(payloadMap), buildFlowRequest(requestMap))
result.success(encodeFlowResponse(response))
}
"continueFederatedAuth" -> {
val redirectUrl = args["redirectUrl"] as? String ?: ""
val actionId = args["actionId"] as? String ?: ""
val applicationId = args["applicationId"] as? String ?: ""
val flowId = args["flowId"] as? String
val challengeToken = args["challengeToken"] as? String
val callbackUri = FederatedAuthSession.launch(activity ?: context, redirectUrl)
val code = callbackUri.getQueryParameter("code")
?: throw IAMException(
ThunderIDErrorCode.INVALID_GRANT,
"Federated sign-in did not return an authorization code"
)
val payload = EmbeddedSignInPayload(
flowId = flowId,
actionId = actionId,
inputs = mapOf("code" to code),
challengeToken = challengeToken
)
val request = EmbeddedFlowRequestConfig(applicationId = applicationId)
val response = client.signIn(payload, request)
result.success(encodeFlowResponse(response))
}
"buildSignInUrl" -> {
result.success(client.buildSignInUrl())
}
Expand Down Expand Up @@ -97,6 +128,10 @@ class ThunderIDMethodHandler(private val context: Context) {
}
} catch (e: IAMException) {
result.error(e.code.value, e.message, null)
} catch (e: CancellationException) {
// User dismissed the Custom Tab without completing federated sign-in — surfaced as a
// typed cancellation so the Dart layer can reset state silently instead of erroring.
result.error("FEDERATED_AUTH_CANCELLED", "User cancelled federated sign-in", null)
Comment on lines +131 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Rethrow CancellationException to maintain structured concurrency.

When catching CancellationException in Kotlin coroutines, it is best practice to rethrow it after handling (such as sending the error via the MethodChannel). Swallowing it prevents the coroutine machinery from properly propagating the cancellation to the parent scope. As per static analysis hints, the caught exception is swallowed, which could lead to the original exception being lost.

🛠️ Proposed fix to rethrow the exception
         } catch (e: CancellationException) {
             // User dismissed the Custom Tab without completing federated sign-in — surfaced as a
             // typed cancellation so the Dart layer can reset state silently instead of erroring.
             result.error("FEDERATED_AUTH_CANCELLED", "User cancelled federated sign-in", null)
+            throw e
         } catch (e: Exception) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (e: CancellationException) {
// User dismissed the Custom Tab without completing federated sign-in — surfaced as a
// typed cancellation so the Dart layer can reset state silently instead of erroring.
result.error("FEDERATED_AUTH_CANCELLED", "User cancelled federated sign-in", null)
} catch (e: CancellationException) {
// User dismissed the Custom Tab without completing federated sign-in — surfaced as a
// typed cancellation so the Dart layer can reset state silently instead of erroring.
result.error("FEDERATED_AUTH_CANCELLED", "User cancelled federated sign-in", null)
throw e
🧰 Tools
🪛 detekt (1.23.8)

[warning] 131-131: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 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 `@android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt`
around lines 131 - 134, Update the CancellationException catch block in
ThunderIDMethodHandler so it reports FEDERATED_AUTH_CANCELLED through
result.error, then rethrows the same exception to preserve coroutine
cancellation propagation and structured concurrency.

Source: Linters/SAST tools

} catch (e: Exception) {
result.error("UNKNOWN_ERROR", e.message, null)
}
Expand Down Expand Up @@ -166,11 +201,14 @@ class ThunderIDMethodHandler(private val context: Context) {
private fun encodeFlowStepData(data: FlowStepData) = mapOf(
"actions" to data.actions?.map { action ->
mapOf(
"id" to action.id.ifEmpty { action.ref ?: action.nextNode ?: "submit" },
"id" to (action.id?.ifEmpty { null } ?: action.ref ?: action.nextNode ?: "submit"),
"ref" to action.ref,
"nextNode" to action.nextNode,
"type" to action.type,
"label" to action.label
"label" to action.label,
"eventType" to action.eventType,
"variant" to action.variant,
"icon" to action.icon
)
},
"inputs" to data.inputs?.map { input ->
Expand All @@ -180,7 +218,25 @@ class ThunderIDMethodHandler(private val context: Context) {
"required" to input.required
)
},
"meta" to data.meta
"meta" to data.meta?.let { encodeFlowMeta(it) }
)

private fun encodeFlowMeta(meta: FlowMeta) = mapOf(
"components" to meta.components?.map { encodeFlowComponent(it) }
)

private fun encodeFlowComponent(comp: FlowComponent): Map<String, Any?> = mapOf(
"id" to comp.id,
"ref" to comp.ref,
"type" to comp.type,
"category" to comp.category,
"label" to comp.label,
"placeholder" to comp.placeholder,
"variant" to comp.variant,
"eventType" to comp.eventType,
"align" to comp.align,
"icon" to comp.icon,
"components" to comp.components?.map { encodeFlowComponent(it) }
)

private fun encodeTokenResponse(r: TokenResponse) = mapOf(
Expand Down
115 changes: 111 additions & 4 deletions ios/Classes/ThunderIDMethodHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ThunderID
@MainActor
final class ThunderIDMethodHandler {
private let client = ThunderIDClient()
private let federatedAuthSession = FederatedAuthSession()

func handle(method: String, args: [String: Any], result: @escaping FlutterResult) async {
do {
Expand All @@ -30,6 +31,9 @@ final class ThunderIDMethodHandler {
let response = try await client.signIn(payload: payload, request: request)
result(encodeFlowResponse(response))

case "continueFederatedAuth":
await handleContinueFederatedAuth(args, result: result)

case "buildSignInUrl":
let url = try client.buildSignInURL()
result(url.absoluteString)
Expand Down Expand Up @@ -107,6 +111,76 @@ final class ThunderIDMethodHandler {
}
}

// MARK: - Federated auth (TRIGGER actions)

/// Resumes a TRIGGER action after the server responded `type: "REDIRECTION"`: opens
/// `redirectUrl` in an `ASWebAuthenticationSession`, extracts the `code` query parameter
/// from the provider's callback, and resubmits the flow with `inputs: {"code": code}` —
/// mirroring `BaseSignIn.handleRedirection` in `ThunderIDSwiftUI` (spec §6.1 federated
/// sign-in extension). Handles its own errors and always calls `result` itself so a
/// cancelled browser session can be reported with a dedicated `FEDERATED_AUTH_CANCELLED`
/// code instead of falling into the generic `UNKNOWN_ERROR` path.
private func handleContinueFederatedAuth(_ args: [String: Any], result: @escaping FlutterResult) async {
let redirectUrlStr = args["redirectUrl"] as? String ?? ""
guard let url = URL(string: redirectUrlStr) else {
result(FlutterError(code: "INVALID_REDIRECT_URI", message: "Invalid redirect URL", details: nil))
return
}
guard let scheme = callbackURLScheme(from: url) else {
result(FlutterError(
code: "INVALID_CONFIGURATION",
message: "Unable to determine callback URL scheme from the authorization URL's " +
"redirect_uri or afterSignInUrl",
details: nil
))
return
}
do {
let callbackURL = try await federatedAuthSession.authenticate(url: url, callbackURLScheme: scheme)
guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "code" })?.value else {
result(FlutterError(
code: "INVALID_GRANT",
message: "Authorization code missing from callback URL",
details: nil
))
return
}
let payload = EmbeddedSignInPayload(
flowId: args["flowId"] as? String,
actionId: args["actionId"] as? String ?? "",
inputs: ["code": code],
challengeToken: args["challengeToken"] as? String
)
let request = buildFlowRequestConfig(from: ["applicationId": args["applicationId"] as? String ?? ""])
let response = try await client.signIn(payload: payload, request: request)
result(encodeFlowResponse(response))
} catch is FederatedAuthSession.CancelledError {
result(FlutterError(code: "FEDERATED_AUTH_CANCELLED", message: "User cancelled federated sign-in", details: nil))
} catch let error as ThunderIDError {
result(FlutterError(code: error.code.rawValue, message: error.message, details: nil))
} catch {
result(FlutterError(code: "UNKNOWN_ERROR", message: error.localizedDescription, details: nil))
}
}

/// Resolves the scheme `ASWebAuthenticationSession` should watch for. `afterSignInUrl` is
/// optional on `ThunderIDConfig` (Android and the Dart API impose no equivalent requirement),
/// so prefer the `redirect_uri` query parameter already present on every standard OAuth2
/// authorization URL, falling back to `afterSignInUrl` only if that's absent.
private func callbackURLScheme(from redirectUrl: URL) -> String? {
if let redirectUri = URLComponents(url: redirectUrl, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "redirect_uri" })?.value,
let scheme = URLComponents(string: redirectUri)?.scheme {
return scheme
}
guard let afterSignInUrl = try? client.getConfiguration().afterSignInUrl,
let scheme = URLComponents(string: afterSignInUrl)?.scheme else {
return nil
}
return scheme
}

// MARK: - Builders

private func buildConfig(from args: [String: Any]) throws -> ThunderIDConfig {
Expand Down Expand Up @@ -187,13 +261,46 @@ final class ThunderIDMethodHandler {
"ref": $0.ref as Any,
"nextNode": $0.nextNode as Any,
"label": $0.label as Any,
"eventType": $0.eventType as Any,
"type": $0.type as Any,
"variant": $0.variant as Any,
"icon": $0.icon as Any,
]
}
}
if let meta = d.meta,
let encoded = try? JSONEncoder().encode(meta),
let obj = try? JSONSerialization.jsonObject(with: encoded) {
result["meta"] = obj
if let meta = d.meta {
result["meta"] = encodeFlowMeta(meta)
}
result["redirectURL"] = d.redirectURL as Any
if let additionalData = d.additionalData {
result["additionalData"] = additionalData.mapValues { $0.value }
}
return result
}

private func encodeFlowMeta(_ meta: FlowMeta) -> [String: Any] {
var result: [String: Any] = [:]
if let components = meta.components {
result["components"] = components.map { encodeFlowComponent($0) }
}
return result
}

private func encodeFlowComponent(_ c: FlowComponent) -> [String: Any] {
var result: [String: Any] = [
"id": c.id as Any,
"ref": c.ref as Any,
"type": c.type as Any,
"category": c.category as Any,
"label": c.label as Any,
"placeholder": c.placeholder as Any,
"variant": c.variant as Any,
"eventType": c.eventType as Any,
"align": c.align as Any,
"icon": c.icon as Any,
]
if let components = c.components {
result["components"] = components.map { encodeFlowComponent($0) }
}
return result
}
Expand Down
6 changes: 5 additions & 1 deletion lib/src/models/thunderid_error.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ enum ThunderIDErrorCode {
networkError,
requestTimeout,
serverError,
unknownError;
unknownError,

// Federated / social login (TRIGGER actions)
federatedAuthCancelled;

static const _codeMap = {
'SDK_NOT_INITIALIZED': ThunderIDErrorCode.sdkNotInitialized,
Expand All @@ -77,6 +80,7 @@ enum ThunderIDErrorCode {
'REQUEST_TIMEOUT': ThunderIDErrorCode.requestTimeout,
'SERVER_ERROR': ThunderIDErrorCode.serverError,
'UNKNOWN_ERROR': ThunderIDErrorCode.unknownError,
'FEDERATED_AUTH_CANCELLED': ThunderIDErrorCode.federatedAuthCancelled,
};

static ThunderIDErrorCode fromString(String code) =>
Expand Down
Loading
Loading