From 4cb7f8fa9329e2e7e3b041b41a29b3c4b8fb067b Mon Sep 17 00:00:00 2001 From: Brion Date: Thu, 16 Jul 2026 14:26:26 +0530 Subject: [PATCH] Add federated authentication support with GitHub and Google buttons - Introduced `FederatedAuthSession` for handling browser redirects during federated login. - Added `ic_provider_github` and `ic_provider_google` drawable resources. - Updated `GitHubButton` and `GoogleButton` components to use images instead of custom drawings. - Simplified `AuthScreen` by removing unnecessary components and parameters. --- build.gradle.kts | 1 + .../dev/thunderid/quickstart/AuthScreen.kt | 58 +------------ .../android/auth/FederatedAuthSession.kt | 47 +++++++++++ .../actions/adapters/GitHubButton.kt | 43 +++------- .../actions/adapters/GoogleButton.kt | 79 +++--------------- .../components/presentation/auth/SignIn.kt | 25 +++--- .../res/drawable-nodpi/ic_provider_github.png | Bin 0 -> 6822 bytes .../res/drawable-nodpi/ic_provider_google.png | Bin 0 -> 5684 bytes 8 files changed, 85 insertions(+), 168 deletions(-) create mode 100644 src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt create mode 100644 src/main/res/drawable-nodpi/ic_provider_github.png create mode 100644 src/main/res/drawable-nodpi/ic_provider_google.png diff --git a/build.gradle.kts b/build.gradle.kts index 882112b..ebd22c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("com.google.code.gson:gson:2.10.1") + implementation("androidx.browser:browser:1.8.0") val composeBom = platform("androidx.compose:compose-bom:2024.02.00") implementation(composeBom) diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt index f31aa7b..f6897d7 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -193,15 +192,8 @@ fun AuthScreen(applicationId: String) { sheetState = sheetState, ) { when (showSheet) { - "login" -> LoginSheetContent( - applicationId = applicationId, - onForgotPassword = { showSheet = "recover" }, - onSignUp = { showSheet = "signup" }, - ) + "login" -> LoginSheetContent(applicationId = applicationId) "signup" -> SignUpSheetContent() - "recover" -> RecoverSheetContent( - onBackToSignIn = { showSheet = "login" }, - ) } } } @@ -219,11 +211,7 @@ private fun SheetTitle(text: String) { } @Composable -private fun LoginSheetContent( - applicationId: String, - onForgotPassword: () -> Unit, - onSignUp: () -> Unit, -) { +private fun LoginSheetContent(applicationId: String) { Column( modifier = Modifier .fillMaxWidth() @@ -234,17 +222,6 @@ private fun LoginSheetContent( SheetTitle("Sign in") Spacer(Modifier.height(8.dp)) SignIn(applicationId = applicationId, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) - Spacer(Modifier.height(8.dp)) - TextButton(onClick = onForgotPassword) { - Text("Forgot password?", color = PrimaryBlue, fontSize = 14.sp) - } - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Don't have an account?", fontSize = 14.sp, color = TextMuted) - TextButton(onClick = onSignUp) { - Text("Create one", color = PrimaryBlue, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - } - } } } @@ -263,37 +240,6 @@ private fun SignUpSheetContent() { } } -@Composable -private fun RecoverSheetContent(onBackToSignIn: () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SheetTitle("Reset password") - Spacer(Modifier.height(8.dp)) - Text( - text = "Enter your email below", - fontSize = 14.sp, - color = TextMuted, - modifier = Modifier.padding(start = 0.dp), - textAlign = TextAlign.Start, - ) - Spacer(Modifier.height(32.dp)) - OutlinedButton( - onClick = onBackToSignIn, - modifier = Modifier.fillMaxWidth().height(48.dp), - shape = RoundedCornerShape(10.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, PrimaryBlue), - colors = ButtonDefaults.outlinedButtonColors(contentColor = PrimaryBlue), - ) { - Text("Back to sign in", fontWeight = FontWeight.Medium) - } - } -} - @Composable private fun ThunderLogoMark( modifier: Modifier = Modifier, diff --git a/src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt b/src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt new file mode 100644 index 0000000..98cbcac --- /dev/null +++ b/src/main/kotlin/dev/thunderid/android/auth/FederatedAuthSession.kt @@ -0,0 +1,47 @@ +package dev.thunderid.android.auth + +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import kotlinx.coroutines.CompletableDeferred + +/** + * Bridges the browser round-trip for TRIGGER (federated/social login) actions. + * + * The flow-execute REDIRECTION mechanism is separate from the whole-app OAuth2/PKCE redirect + * mode in [dev.thunderid.android.ThunderIDClient.buildSignInUrl]/`handleRedirectCallback` — this + * only opens a Custom Tab and waits for the app's registered callback scheme to come back. + * + * The hosting Activity must forward its deep-link `Uri` to [onRedirect] from `onNewIntent`, + * the same way [dev.thunderid.compose.components.flow.Callback] expects the app to hand it a + * deep-link URL explicitly. Call [cancelIfPending] from `onResume` to resolve gracefully if the + * user dismissed the browser without completing sign-in. + */ +object FederatedAuthSession { + private var pending: CompletableDeferred? = null + + suspend fun launch( + context: Context, + redirectUrl: String, + ): Uri { + pending?.cancel() + val deferred = CompletableDeferred() + pending = deferred + CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(redirectUrl)) + return try { + deferred.await() + } finally { + if (pending === deferred) pending = null + } + } + + fun onRedirect(uri: Uri) { + pending?.complete(uri) + pending = null + } + + fun cancelIfPending() { + pending?.cancel() + pending = null + } +} diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt index 42e9d3c..2f66c17 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt @@ -18,13 +18,13 @@ package dev.thunderid.compose.components.actions.adapters -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size -import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import dev.thunderid.android.R /** * "Continue with GitHub" federated sign-in trigger, styled to match the outlined action @@ -42,35 +42,12 @@ fun GitHubButton( isLoading = isLoading, onClick = onClick, modifier = modifier, - icon = { GitHubGlyph() }, + icon = { + Image( + painter = painterResource(id = R.drawable.ic_provider_github), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, ) } - -/** - * Simplified GitHub "octocat" silhouette, drawn with plain [Canvas] circles rather than ported - * SVG path data — a rounded head with two ears, visually recognizable without pixel-perfect - * brand fidelity. - */ -@Composable -private fun GitHubGlyph() { - val glyphColor = LocalContentColor.current - Canvas(modifier = Modifier.size(18.dp)) { - val headRadius = size.minDimension * 0.42f - val center = Offset(size.width / 2f, size.height / 2f) - val earRadius = size.minDimension * 0.14f - - // Ears. - drawCircle( - color = glyphColor, - radius = earRadius, - center = Offset(center.x - headRadius * 0.62f, center.y - headRadius * 0.62f), - ) - drawCircle( - color = glyphColor, - radius = earRadius, - center = Offset(center.x + headRadius * 0.62f, center.y - headRadius * 0.62f), - ) - // Head/body. - drawCircle(color = glyphColor, radius = headRadius, center = center) - } -} diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt index 6ace4b1..fdab5dd 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt @@ -18,15 +18,13 @@ package dev.thunderid.compose.components.actions.adapters -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import dev.thunderid.android.R /** * "Continue with Google" federated sign-in trigger, styled to match the outlined action @@ -44,69 +42,12 @@ fun GoogleButton( isLoading = isLoading, onClick = onClick, modifier = modifier, - icon = { GoogleGlyph() }, + icon = { + Image( + painter = painterResource(id = R.drawable.ic_provider_google), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, ) } - -/** - * Simplified four-color Google "G" mark, drawn with plain [Canvas] arcs rather than ported SVG - * path data — visually recognizable without pixel-perfect brand fidelity. - */ -@Composable -private fun GoogleGlyph() { - Canvas(modifier = Modifier.size(18.dp)) { - val strokeWidth = size.minDimension * 0.22f - val radius = (size.minDimension - strokeWidth) / 2f - val center = Offset(size.width / 2f, size.height / 2f) - val topLeft = Offset(center.x - radius, center.y - radius) - val arcSize = Size(radius * 2f, radius * 2f) - val stroke = Stroke(width = strokeWidth) - - // Red: top arc. - drawArc( - color = Color(0xFFEA4335), - startAngle = 270f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - // Green: bottom-right arc. - drawArc( - color = Color(0xFF34A853), - startAngle = 0f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - // Yellow: bottom-left arc. - drawArc( - color = Color(0xFFFBBC05), - startAngle = 90f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - // Blue: top-left arc, plus the crossbar of the "G". - drawArc( - color = Color(0xFF4285F4), - startAngle = 180f, - sweepAngle = 90f, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = stroke, - ) - drawLine( - color = Color(0xFF4285F4), - start = center, - end = Offset(center.x + radius, center.y), - strokeWidth = strokeWidth, - ) - } -} diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt index 33d16d8..5f3ac2a 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt @@ -127,10 +127,19 @@ class SignInState { } } +/** + * The real Flow Execution API identifies the same node with `ref` on the flat `data.actions` + * array but `id` on the matching node inside `data.meta.components` — the two never share a + * field name, so callers must compare whichever identifier each side actually populated. + */ +private fun FlowAction.identifierKey(): String? = ref ?: id + +private fun FlowComponent.identifierKey(): String? = ref ?: id + /** * Fills in any `null` presentation fields on the flat `actions` array (label, eventType, - * variant, icon) from the matching `ACTION`-typed node in the component tree, matched by `ref` - * (falling back to `id`). Explicit flat values always win. + * variant, icon) from the matching `ACTION`-typed node in the component tree, matched by + * whichever of `ref`/`id` each side populated. Explicit flat values always win. */ private fun enrichActions( actions: List, @@ -138,10 +147,8 @@ private fun enrichActions( ): List { val actionComponents = flattenActionComponents(components) return actions.map { action -> - val match = - actionComponents.firstOrNull { - (it.ref != null && it.ref == action.ref) || (it.id != null && it.id == action.id) - } ?: return@map action + val key = action.identifierKey() ?: return@map action + val match = actionComponents.firstOrNull { it.identifierKey() == key } ?: return@map action action.copy( label = action.label ?: match.label, eventType = action.eventType ?: match.eventType, @@ -323,10 +330,8 @@ private fun ActionComponentView( i18n: ThunderIDI18n, modifier: Modifier = Modifier, ) { - val action = - signInState.actions.firstOrNull { - (it.ref != null && it.ref == component.ref) || (it.id != null && it.id == component.id) - } ?: return + val componentKey = component.identifierKey() ?: return + val action = signInState.actions.firstOrNull { it.identifierKey() == componentKey } ?: return val actionId = action.id ?: action.ref ?: return val resolver = signInState.templateResolver val label = diff --git a/src/main/res/drawable-nodpi/ic_provider_github.png b/src/main/res/drawable-nodpi/ic_provider_github.png new file mode 100644 index 0000000000000000000000000000000000000000..76584490c162f2cfc6be2208b09a83ff49140486 GIT binary patch literal 6822 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