├── .gitignore ├── .idea ├── .gitignore ├── AndroidProjectSystem.xml ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── runConfigurations.xml ├── studiobot.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── tdcolvin │ │ └── passkeyauthdemo │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── tdcolvin │ │ │ └── passkeyauthdemo │ │ │ ├── MainActivity.kt │ │ │ ├── PasskeyDemoViewModel.kt │ │ │ ├── ui │ │ │ ├── PasskeyDemoNav.kt │ │ │ ├── SignedInScreen.kt │ │ │ ├── SignedOutScreen.kt │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── util │ │ │ └── MyCookieJar.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── tdcolvin │ └── passkeyauthdemo │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── keystore-password-aaaaaa.jks └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /app/release 17 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/AndroidProjectSystem.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/studiobot.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Passkey demo app on Android 2 | 3 | This app demonstrates a full end-to-end passkey implementation in Android, using Jetpack Credential Manager and Compose. 4 | 5 | For the server side implementation, see https://github.com/tdcolvin/PasskeyAuthDemoServer. 6 | 7 | It authenticates to a demo server I've set up: auth.tomcolvin.co.uk. 8 | 9 | Please open an issue if you hit any problems. And if you need help implementing passkeys in your own app (or anything else Android-y) then I'm [available as a freelancer](https://www.tomcolvin.co.uk) 😁. 10 | Contact me on [LinkedIn](https://www.linkedin.com/in/tdcolvin/) or [Bluesky](https://bsky.app/profile/tomcolvin.co.uk). 11 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.kotlin.compose) 5 | } 6 | 7 | android { 8 | namespace = "com.tdcolvin.passkeyauthdemo" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | applicationId = "com.tdcolvin.passkeyauthdemo" 13 | minSdk = 28 14 | targetSdk = 35 15 | versionCode = 1 16 | versionName = "1.0" 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | signingConfigs { 22 | signingConfigs.create("default") { 23 | keyAlias = "key0" 24 | keyPassword = "aaaaaa" 25 | storeFile = file("../keystore-password-aaaaaa.jks") 26 | storePassword = "aaaaaa" 27 | } 28 | } 29 | 30 | buildTypes { 31 | release { 32 | isMinifyEnabled = false 33 | proguardFiles( 34 | getDefaultProguardFile("proguard-android-optimize.txt"), 35 | "proguard-rules.pro" 36 | ) 37 | signingConfig = signingConfigs["default"] 38 | } 39 | debug { 40 | signingConfig = signingConfigs["default"] 41 | } 42 | } 43 | compileOptions { 44 | sourceCompatibility = JavaVersion.VERSION_11 45 | targetCompatibility = JavaVersion.VERSION_11 46 | } 47 | kotlinOptions { 48 | jvmTarget = "11" 49 | } 50 | buildFeatures { 51 | compose = true 52 | } 53 | } 54 | 55 | dependencies { 56 | 57 | implementation(libs.androidx.core.ktx) 58 | implementation(libs.androidx.lifecycle.runtime.ktx) 59 | implementation(libs.androidx.activity.compose) 60 | implementation(platform(libs.androidx.compose.bom)) 61 | implementation(libs.androidx.ui) 62 | implementation(libs.androidx.ui.graphics) 63 | implementation(libs.androidx.ui.tooling.preview) 64 | implementation(libs.androidx.material3) 65 | 66 | implementation(libs.lifecycle.viewmodel.ktx) 67 | implementation(libs.androidx.lifecycle.viewmodel.compose) 68 | 69 | implementation(libs.okhttp) 70 | 71 | implementation(libs.androidx.credentials) 72 | 73 | // Needed for credentials support from play services, for devices running Android 13 and below. 74 | implementation(libs.androidx.credentials.play.services.auth) 75 | 76 | testImplementation(libs.junit) 77 | androidTestImplementation(libs.androidx.junit) 78 | androidTestImplementation(libs.androidx.espresso.core) 79 | androidTestImplementation(platform(libs.androidx.compose.bom)) 80 | androidTestImplementation(libs.androidx.ui.test.junit4) 81 | debugImplementation(libs.androidx.ui.tooling) 82 | debugImplementation(libs.androidx.ui.test.manifest) 83 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | -if class androidx.credentials.CredentialManager 24 | -keep class androidx.credentials.playservices.** { 25 | *; 26 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/tdcolvin/passkeyauthdemo/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.tdcolvin.passkeyauthdemo", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.Scaffold 10 | import androidx.compose.ui.Modifier 11 | import com.tdcolvin.passkeyauthdemo.ui.PasskeyDemoNav 12 | import com.tdcolvin.passkeyauthdemo.ui.theme.PasskeyAuthDemoAndroidTheme 13 | 14 | class MainActivity : ComponentActivity() { 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | enableEdgeToEdge() 18 | setContent { 19 | PasskeyAuthDemoAndroidTheme { 20 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> 21 | PasskeyDemoNav( 22 | modifier = Modifier.fillMaxSize().padding(innerPadding) 23 | ) 24 | } 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/PasskeyDemoViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.tdcolvin.passkeyauthdemo.util.MyCookieJar 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import okhttp3.HttpUrl 8 | import okhttp3.MediaType.Companion.toMediaType 9 | import okhttp3.OkHttpClient 10 | import okhttp3.Request 11 | import okhttp3.RequestBody.Companion.toRequestBody 12 | 13 | class PasskeyDemoViewModel: ViewModel() { 14 | private val okHttpClient = OkHttpClient.Builder().cookieJar(MyCookieJar()).build() 15 | 16 | suspend fun getPasskeyRegisterRequestJson(username: String): String = withContext(Dispatchers.IO) { 17 | val url = HttpUrl.Builder() 18 | .scheme("https") 19 | .host("auth.tomcolvin.co.uk") 20 | .addPathSegment("generate-registration-options") 21 | .addQueryParameter("username", username) 22 | .build() 23 | val request = Request.Builder() 24 | .url(url) 25 | .build() 26 | 27 | val response = okHttpClient.newCall(request).execute() 28 | response.body?.string() ?: throw Exception("No response body") 29 | } 30 | 31 | suspend fun sendRegistrationResponse( 32 | registrationResponseJson: String, 33 | ) = withContext(Dispatchers.IO) { 34 | val url = HttpUrl.Builder() 35 | .scheme("https") 36 | .host("auth.tomcolvin.co.uk") 37 | .addPathSegment("verify-registration") 38 | .build() 39 | val request = Request.Builder() 40 | .url(url) 41 | .post(registrationResponseJson.toRequestBody("application/json".toMediaType())) 42 | .build() 43 | 44 | val response = okHttpClient.newCall(request).execute() 45 | if (response.code != 200) { 46 | throw Exception("Registration failed: ${response.body?.string()}") 47 | } 48 | response.body?.string() ?: "{}" 49 | } 50 | 51 | suspend fun getPasskeyAuthenticationRequestJson(username: String): String = withContext( 52 | Dispatchers.IO) { 53 | val url = HttpUrl.Builder() 54 | .scheme("https") 55 | .host("auth.tomcolvin.co.uk") 56 | .addPathSegment("generate-authentication-options") 57 | .addQueryParameter("username", username) 58 | .build() 59 | val request = Request.Builder() 60 | .url(url) 61 | .build() 62 | 63 | val response = okHttpClient.newCall(request).execute() 64 | response.body?.string() ?: throw Exception("No response body") 65 | } 66 | 67 | suspend fun sendAuthenticationResponse(authenticationResponseJson: String): String = withContext( 68 | Dispatchers.IO) { 69 | val url = HttpUrl.Builder() 70 | .scheme("https") 71 | .host("auth.tomcolvin.co.uk") 72 | .addPathSegment("verify-authentication") 73 | .build() 74 | val request = Request.Builder() 75 | .url(url) 76 | .post(authenticationResponseJson.toRequestBody("application/json".toMediaType())) 77 | .build() 78 | 79 | val response = okHttpClient.newCall(request).execute() 80 | response.body?.string() ?: throw Exception("No response body") 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/ui/PasskeyDemoNav.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo.ui 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.runtime.setValue 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.unit.dp 13 | import androidx.credentials.CredentialManager 14 | import androidx.lifecycle.viewmodel.compose.viewModel 15 | import com.tdcolvin.passkeyauthdemo.PasskeyDemoViewModel 16 | 17 | @Composable 18 | fun PasskeyDemoNav( 19 | modifier: Modifier = Modifier, 20 | viewModel: PasskeyDemoViewModel = viewModel() 21 | ) { 22 | val localContext = LocalContext.current 23 | 24 | val credentialManager = remember { CredentialManager.create(localContext) } 25 | 26 | val username = remember { 27 | String((0..5).map { "abcdefghijklmnopqrstuvwxyz0123456789".random() }.toCharArray()) 28 | } 29 | var signedIn by remember { mutableStateOf(false) } 30 | 31 | if (signedIn) { 32 | SignedInScreen( 33 | modifier = modifier.fillMaxSize().padding(20.dp), 34 | username = username, 35 | onSignOut = { signedIn = false } 36 | ) 37 | } 38 | else { 39 | SignedOutScreen( 40 | modifier = modifier.fillMaxSize().padding(20.dp), 41 | username = username, 42 | credentialManager = credentialManager, 43 | getPasskeyRegisterRequestJson = viewModel::getPasskeyRegisterRequestJson, 44 | sendRegistrationResponse = viewModel::sendRegistrationResponse, 45 | getPasskeyAuthenticationRequestJson = viewModel::getPasskeyAuthenticationRequestJson, 46 | sendAuthenticationResponse = viewModel::sendAuthenticationResponse, 47 | onSignedIn = { signedIn = true } 48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/ui/SignedInScreen.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo.ui 2 | 3 | import android.content.res.Configuration 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.dp 18 | import com.tdcolvin.passkeyauthdemo.ui.theme.PasskeyAuthDemoAndroidTheme 19 | 20 | @Composable 21 | fun SignedInScreen( 22 | modifier: Modifier = Modifier, 23 | username: String, 24 | onSignOut: () -> Unit 25 | ) { 26 | Column( 27 | modifier = modifier, 28 | verticalArrangement = Arrangement.spacedBy(30.dp), 29 | horizontalAlignment = Alignment.CenterHorizontally 30 | ) { 31 | Text( 32 | textAlign = TextAlign.Center, 33 | text = "Successfully signed in as $username", 34 | style = MaterialTheme.typography.headlineMedium 35 | ) 36 | Text( 37 | modifier = Modifier.padding(top = 20.dp), 38 | textAlign = TextAlign.Center, 39 | text = "Registered and authenticated to auth.tomcolvin.co.uk", 40 | ) 41 | 42 | Button( 43 | modifier = Modifier.fillMaxWidth().padding(top = 20.dp), 44 | onClick = onSignOut 45 | ) { 46 | Text("Sign out") 47 | } 48 | } 49 | } 50 | 51 | @Preview(showBackground = false) 52 | @Composable 53 | fun SignedInScreenPreview_Light() { 54 | PasskeyAuthDemoAndroidTheme { 55 | Surface { 56 | SignedInScreen(username = "John Doe", onSignOut = {}) 57 | } 58 | } 59 | } 60 | 61 | @Preview(showBackground = false, uiMode = Configuration.UI_MODE_NIGHT_YES) 62 | @Composable 63 | fun SignedInScreenPreview_Dark() { 64 | PasskeyAuthDemoAndroidTheme { 65 | Surface { 66 | SignedInScreen(username = "John Doe", onSignOut = {}) 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/ui/SignedOutScreen.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo.ui 2 | 3 | import android.util.Log 4 | import androidx.activity.compose.LocalActivity 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.rememberCoroutineScope 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.tooling.preview.Preview 22 | import androidx.compose.ui.unit.dp 23 | import androidx.credentials.CreatePublicKeyCredentialRequest 24 | import androidx.credentials.CreatePublicKeyCredentialResponse 25 | import androidx.credentials.CredentialManager 26 | import androidx.credentials.GetCredentialRequest 27 | import androidx.credentials.GetPublicKeyCredentialOption 28 | import androidx.credentials.PublicKeyCredential 29 | import com.tdcolvin.passkeyauthdemo.ui.theme.PasskeyAuthDemoAndroidTheme 30 | import kotlinx.coroutines.launch 31 | 32 | @Composable 33 | fun SignedOutScreen( 34 | modifier: Modifier = Modifier, 35 | username: String, 36 | credentialManager: CredentialManager?, 37 | getPasskeyRegisterRequestJson: suspend (String) -> String, 38 | sendRegistrationResponse: suspend (String) -> String, 39 | getPasskeyAuthenticationRequestJson: suspend (String) -> String, 40 | sendAuthenticationResponse: suspend (String) -> String, 41 | onSignedIn: () -> Unit 42 | ) { 43 | var error by remember { mutableStateOf(null) } 44 | var isWorking by remember { mutableStateOf(false) } 45 | 46 | Column( 47 | modifier = modifier, 48 | horizontalAlignment = Alignment.CenterHorizontally 49 | ) { 50 | Text( 51 | text = "Passkey demo app", 52 | textAlign = TextAlign.Center, 53 | style = MaterialTheme.typography.headlineMedium 54 | ) 55 | 56 | Text(text = "Authenticating to auth.tomcolvin.co.uk", textAlign = TextAlign.Center) 57 | 58 | Text( 59 | modifier = Modifier.padding(top = 16.dp), 60 | text = "Your randomly generated username: $username", 61 | textAlign = TextAlign.Center 62 | ) 63 | 64 | 65 | SignUpWithPasskey( 66 | modifier = Modifier.padding(top = 40.dp).fillMaxWidth(), 67 | enabled = !isWorking, 68 | username = username, 69 | credentialManager = credentialManager, 70 | getPasskeyRegisterRequestJson = getPasskeyRegisterRequestJson, 71 | sendRegistrationResponse = sendRegistrationResponse, 72 | onBeginSignUp = { 73 | error = null 74 | isWorking = true 75 | }, 76 | onSignedUp = { 77 | onSignedIn() 78 | isWorking = false 79 | }, 80 | onError = { 81 | error = it 82 | isWorking = false 83 | } 84 | ) 85 | SignInWithPasskey( 86 | modifier = Modifier.fillMaxWidth(), 87 | enabled = !isWorking, 88 | username = username, 89 | credentialManager = credentialManager, 90 | getPasskeyAuthenticationRequestJson = getPasskeyAuthenticationRequestJson, 91 | sendAuthenticationResponse = sendAuthenticationResponse, 92 | onBeginSignIn = { 93 | error = null 94 | isWorking = true 95 | }, 96 | onSignedIn = { 97 | onSignedIn() 98 | isWorking = false 99 | }, 100 | onError = { 101 | error = it 102 | isWorking = false 103 | } 104 | ) 105 | 106 | error?.message?.let { errorMessage -> 107 | Text(modifier = Modifier.padding(top = 40.dp), text = errorMessage) 108 | } 109 | } 110 | } 111 | 112 | @Composable 113 | fun SignUpWithPasskey( 114 | modifier: Modifier = Modifier, 115 | enabled: Boolean = true, 116 | username: String, 117 | credentialManager: CredentialManager?, 118 | getPasskeyRegisterRequestJson: suspend (String) -> String, 119 | sendRegistrationResponse: suspend (String) -> String, 120 | onBeginSignUp: () -> Unit, 121 | onSignedUp: () -> Unit, 122 | onError: (Exception) -> Unit 123 | ) { 124 | val localActivity = LocalActivity.current 125 | val signUpScope = rememberCoroutineScope() 126 | 127 | var isRegistering by remember { mutableStateOf(false) } 128 | 129 | fun doSignUp() { 130 | localActivity ?: return 131 | credentialManager ?: return 132 | 133 | signUpScope.launch { 134 | onBeginSignUp() 135 | isRegistering = true 136 | 137 | try { 138 | val registerRequestJson = getPasskeyRegisterRequestJson(username) 139 | 140 | val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest( 141 | requestJson = registerRequestJson, 142 | preferImmediatelyAvailableCredentials = false 143 | ) 144 | val createCredentialResponse = credentialManager.createCredential( 145 | context = localActivity, 146 | request = createPublicKeyCredentialRequest 147 | ) 148 | 149 | if (createCredentialResponse !is CreatePublicKeyCredentialResponse) { 150 | throw Exception("Incorrect response type") 151 | } 152 | 153 | sendRegistrationResponse(createCredentialResponse.registrationResponseJson) 154 | 155 | onSignedUp() 156 | } 157 | catch (e: Exception) { 158 | onError(e) 159 | } 160 | finally { 161 | isRegistering = false 162 | } 163 | } 164 | } 165 | 166 | Button( 167 | modifier = modifier, 168 | onClick = { if (enabled) doSignUp() } 169 | ) { 170 | Text(if (isRegistering) "Registering..." else "Sign up (=register) with Passkey") 171 | } 172 | } 173 | 174 | @Composable 175 | fun SignInWithPasskey( 176 | modifier: Modifier = Modifier, 177 | enabled: Boolean = true, 178 | username: String, 179 | credentialManager: CredentialManager?, 180 | getPasskeyAuthenticationRequestJson: suspend (String) -> String, 181 | sendAuthenticationResponse: suspend (String) -> String, 182 | onBeginSignIn: () -> Unit, 183 | onSignedIn: () -> Unit, 184 | onError: (Exception) -> Unit 185 | ) { 186 | val localActivity = LocalActivity.current 187 | val signInScope = rememberCoroutineScope() 188 | 189 | var isAuthenticating by remember { mutableStateOf(false) } 190 | 191 | fun doSignIn() { 192 | localActivity ?: return 193 | credentialManager ?: return 194 | 195 | onBeginSignIn() 196 | isAuthenticating = true 197 | 198 | signInScope.launch { 199 | try { 200 | val authenticationRequestJson = getPasskeyAuthenticationRequestJson(username) 201 | Log.v("passkey", "Authentication request JSON: $authenticationRequestJson") 202 | 203 | val getPublicKeyCredentialOption = GetPublicKeyCredentialOption( 204 | requestJson = authenticationRequestJson 205 | ) 206 | val signInRequest = GetCredentialRequest(listOf(getPublicKeyCredentialOption)) 207 | 208 | val result = credentialManager.getCredential( 209 | context = localActivity, 210 | request = signInRequest 211 | ) 212 | 213 | val credential = result.credential 214 | 215 | if (credential !is PublicKeyCredential) { 216 | throw Exception("Incorrect credential type") 217 | } 218 | 219 | val responseJson = credential.authenticationResponseJson 220 | 221 | sendAuthenticationResponse(responseJson) 222 | 223 | onSignedIn() 224 | } 225 | catch (e: Exception) { 226 | onError(e) 227 | } 228 | finally { 229 | isAuthenticating = false 230 | } 231 | } 232 | } 233 | 234 | Button( 235 | modifier = modifier, 236 | onClick = { if (enabled) doSignIn() } 237 | ) { 238 | Text(if (isAuthenticating) "Authenticating..." else "Sign in (=authenticate) with Passkey") 239 | } 240 | } 241 | 242 | @Preview(showBackground = false) 243 | @Composable 244 | fun SignedOutScreen_Preview() { 245 | PasskeyAuthDemoAndroidTheme { 246 | Surface { 247 | SignedOutScreen( 248 | username = "test", 249 | credentialManager = null, 250 | sendRegistrationResponse = { "" }, 251 | sendAuthenticationResponse = { "" }, 252 | getPasskeyRegisterRequestJson = { "" }, 253 | getPasskeyAuthenticationRequestJson = { "" }, 254 | onSignedIn = { } 255 | ) 256 | } 257 | } 258 | } -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.darkColorScheme 6 | import androidx.compose.material3.lightColorScheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.Color 9 | 10 | private val DarkColorScheme = darkColorScheme( 11 | primary = Color(0xFFDE1659), 12 | onPrimary = Color.White, 13 | secondary = PurpleGrey80, 14 | tertiary = Pink80, 15 | 16 | background = Color.Black, 17 | onBackground = Color.White, 18 | 19 | surface = Color.Black, 20 | onSurface = Color.White 21 | ) 22 | 23 | private val LightColorScheme = lightColorScheme( 24 | primary = Color.Black, 25 | onPrimary = Color.White, 26 | secondary = PurpleGrey40, 27 | tertiary = Pink40, 28 | 29 | background = Color(0xFFDE1659), 30 | onBackground = Color.White, 31 | 32 | surface = Color(0xFFDE1659), 33 | onSurface = Color.White, 34 | /* Other default colors to override 35 | background = Color(0xFFFFFBFE), 36 | surface = Color(0xFFFFFBFE), 37 | onPrimary = Color.White, 38 | onSecondary = Color.White, 39 | onTertiary = Color.White, 40 | onBackground = Color(0xFF1C1B1F), 41 | onSurface = Color(0xFF1C1B1F), 42 | */ 43 | ) 44 | 45 | @Composable 46 | fun PasskeyAuthDemoAndroidTheme( 47 | darkTheme: Boolean = isSystemInDarkTheme(), 48 | content: @Composable () -> Unit 49 | ) { 50 | val colorScheme = when { 51 | darkTheme -> DarkColorScheme 52 | else -> LightColorScheme 53 | } 54 | 55 | MaterialTheme( 56 | colorScheme = colorScheme, 57 | typography = Typography, 58 | content = content 59 | ) 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/tdcolvin/passkeyauthdemo/util/MyCookieJar.kt: -------------------------------------------------------------------------------- 1 | package com.tdcolvin.passkeyauthdemo.util 2 | 3 | import okhttp3.Cookie 4 | import okhttp3.CookieJar 5 | import okhttp3.HttpUrl 6 | 7 | class MyCookieJar : CookieJar { 8 | private val cookieStore = mutableMapOf>() 9 | 10 | override fun saveFromResponse(url: HttpUrl, cookies: List) { 11 | val storedCookies = cookieStore.getOrDefault(url.host, emptyList()) 12 | cookieStore[url.host] = storedCookies + cookies 13 | } 14 | 15 | override fun loadForRequest(url: HttpUrl) = cookieStore[url.host] ?: emptyList() 16 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdcolvin/PasskeyAuthDemoAndroid/35a68c8ce494a227905cd956dc93036e04cf2b09/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | PasskeyAuthDemoAndroid 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |