From fa2d3b3cdaed5a6d54150cd6a163c64fc191857a Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 28 Jun 2024 15:05:21 -0600 Subject: [PATCH 01/22] PSG-4172: Passage OIDC added, passage.kt updated --- .../main/java/id/passage/android/Passage.kt | 43 +++++- .../java/id/passage/android/PassageOIDC.kt | 134 ++++++++++++++++++ .../android/exceptions/FinishOIDCException.kt | 45 ++++++ 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 passage/src/main/java/id/passage/android/PassageOIDC.kt create mode 100644 passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 4111093..ef8b104 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -38,8 +38,10 @@ public final class Passage( internal companion object { internal const val TAG = "Passage" internal var BASE_PATH = "https://auth.passage.id/v1" - + internal lateinit var BASE_PATH_OIDC : String + internal lateinit var Package_NAME : String internal lateinit var appId: String + internal lateinit var clientSecret: String internal lateinit var authOrigin: String internal var language: String? = null @@ -80,7 +82,10 @@ public final class Passage( init { authOrigin = getRequiredResourceFromApp(activity, "passage_auth_origin") Companion.appId = appId ?: getRequiredResourceFromApp(activity, "passage_app_id") + clientSecret = getRequiredResourceFromApp(activity, "passage_client_secret") language = getOptionalResourceFromApp(activity, "passage_language") + BASE_PATH_OIDC = "https://$authOrigin" + Package_NAME = activity.packageName val usePassageStore = getOptionalResourceFromApp(activity, "use_passage_store") if (usePassageStore != "false") { @@ -654,4 +659,40 @@ public final class Passage( tokenStore.setTokens(authResult) } // endregion + + + // region OIDC Methods + + /** + * Authorize with OIDC + * + * Authorizes user via a OIDC Login feature. + */ + public suspend fun startOIDC() { + PassageOIDC.openChromeTab( + appInfo().id, + activity, + authUrl = "${BASE_PATH_OIDC}/authorize", + ) + } + + /** + * Finish OIDC login + * + * Finishes a OIDC login/sign up by exchanging the auth code for Passage tokens. + * @param code The code returned from the OIDC login. + * @return PassageAuthResult + * @throws FinishOIDCException + */ + + suspend fun finishOIDC(code: String) { + try { + val authResult = PassageOIDC.finishOIDC(code) + if (authResult != null) handleAuthResult(authResult) + + } catch (e : Exception) { + throw FinishOIDCException.convert(e) + } + } + // endregion } diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageOIDC.kt new file mode 100644 index 0000000..9890c66 --- /dev/null +++ b/passage/src/main/java/id/passage/android/PassageOIDC.kt @@ -0,0 +1,134 @@ +package id.passage.android + +import android.app.Activity +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import id.passage.android.model.AuthResult +import id.passage.client.infrastructure.ClientException +import id.passage.client.infrastructure.ServerException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +internal class PassageOIDC { + internal companion object { + internal var verifier = "" + private const val CODE_CHALLENGE_METHOD = "S256" + private const val SECRET_STRING_LENGTH = 32 + + internal fun openChromeTab( + appId: String, + activity: Activity, + authUrl: String, + ) { + val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.Package_NAME}/callback" + val state = getRandomString() + val randomString = getRandomString() + verifier = randomString + val codeChallenge = sha256Hash(randomString) + val newParams = listOf( + "client_id" to appId, + "redirect_uri" to redirectUri, + "state" to state, + "code_challenge" to codeChallenge, + "code_challenge_method" to CODE_CHALLENGE_METHOD, + "scope" to "openid", + "response_type" to "code", + ).joinToString("&") { + (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } + val url = "${authUrl}?${newParams}" + val intent = CustomTabsIntent.Builder().build() + intent.launchUrl(activity, Uri.parse(url)) + } + + private fun getRandomString(): String { + val digits = '0'..'9' + val upperCaseLetters = 'A'..'Z' + val lowerCaseLetters = 'a'..'z' + val characters = + (digits + upperCaseLetters + lowerCaseLetters) + .joinToString("") + val random = SecureRandom() + val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) + for (i in 0 until SECRET_STRING_LENGTH) { + val randomIndex = random.nextInt(characters.length) + stringBuilder.append(characters[randomIndex]) + } + return stringBuilder.toString() + } + + private fun sha256Hash(randomString: String): String { + val bytes = randomString.toByteArray() + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + } + + + internal suspend fun finishOIDC(code: String) : AuthResult? { + val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.Package_NAME}/callback" + var authResult : AuthResult? + val client = OkHttpClient() + val moshi = Moshi.Builder() + .build() + val jsonAdapter = moshi.adapter(OIDCResponse::class.java) + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = "{\"code\":\"$code\"}".toRequestBody(mediaType) + + val params = listOf( + "grant_type" to "authorization_code", + "code" to code, + "client_id" to Passage.appId, + "verifier" to verifier, + "client_secret" to Passage.clientID, + "redirect_uri" to redirectUri + ).joinToString("&") { (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } + + val url = "${Passage.BASE_PATH_OIDC}/token?$params" + val request = Request.Builder() + .url(url) + .post(requestBody) + .build() + + withContext(Dispatchers.IO) { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + if (response.code == 500) + throw ServerException("Server error : ${response.code} ${response.message}", response.code) + throw ClientException("Client error : ${response.code} ${response.message}", response.code) + } + val responseBody = response.body?.string() + if (responseBody != null) { + val apiResponse = jsonAdapter.fromJson(responseBody)!! + authResult = AuthResult( + authToken = apiResponse.access_token, + redirectUrl = "", + refreshToken = apiResponse.refresh_token, + refreshTokenExpiration = null + ) + + } + else + throw Exception("Response body is null : ${response.code} ${response.message}") + } + } + return authResult + } + } +} + +@JsonClass(generateAdapter = true) +data class OIDCResponse(val access_token: String, val refresh_token: String?) diff --git a/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt b/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt new file mode 100644 index 0000000..e719aa1 --- /dev/null +++ b/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt @@ -0,0 +1,45 @@ +@file:Suppress("RedundantVisibilityModifier") + +package id.passage.android.exceptions + +import id.passage.client.infrastructure.ClientException +import id.passage.client.infrastructure.ServerException + +/** + * Thrown when there's an error finishing OIDC login/sign up + * + * @see FinishOIDCException + */ +public open class FinishOIDCException(message: String) : PassageException(message) { + internal companion object { + internal fun convert(e: Exception): FinishOIDCException { + val message = e.message ?: e.toString() + return when (e) { + is ClientException -> convertClientException(e) + is ServerException -> FinishOIDCServerException(message) + else -> FinishOIDCException(message) + } + } + + private fun convertClientException(e: ClientException): FinishOIDCException { + return when (e.statusCode.toString()) { + "400" -> { + FinishOIDCBadRequestException(e.message.toString()) + } + else -> { + FinishOIDCException(e.message.toString()) + } + } + } + } +} + +/** + * Thrown when server returns bad request due to the invalid info. + */ +public class FinishOIDCBadRequestException(message: String) : FinishOIDCException(message) + +/** + * Thrown when Passage internal server error occurs. + */ +public class FinishOIDCServerException(message: String) : FinishOIDCException(message) From a59db696ad2099dad010b366a13d472ba3c03c6c Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 28 Jun 2024 15:18:15 -0600 Subject: [PATCH 02/22] Typo fix --- passage/src/main/java/id/passage/android/PassageOIDC.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageOIDC.kt index 9890c66..8fe22e1 100644 --- a/passage/src/main/java/id/passage/android/PassageOIDC.kt +++ b/passage/src/main/java/id/passage/android/PassageOIDC.kt @@ -91,7 +91,7 @@ internal class PassageOIDC { "code" to code, "client_id" to Passage.appId, "verifier" to verifier, - "client_secret" to Passage.clientID, + "client_secret" to Passage.clientSecret, "redirect_uri" to redirectUri ).joinToString("&") { (key, value) -> "$key=${URLEncoder.encode(value, "UTF-8")}" From 9429f27dcf27a9d313967d635c94648b72a6b8c8 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 28 Jun 2024 15:22:02 -0600 Subject: [PATCH 03/22] lint checks fixes --- .../main/java/id/passage/android/Passage.kt | 12 ++- .../java/id/passage/android/PassageOIDC.kt | 90 ++++++++++--------- 2 files changed, 52 insertions(+), 50 deletions(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index ef8b104..8f901d0 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -38,8 +38,8 @@ public final class Passage( internal companion object { internal const val TAG = "Passage" internal var BASE_PATH = "https://auth.passage.id/v1" - internal lateinit var BASE_PATH_OIDC : String - internal lateinit var Package_NAME : String + internal lateinit var BASE_PATH_OIDC: String + internal lateinit var Package_NAME: String internal lateinit var appId: String internal lateinit var clientSecret: String internal lateinit var authOrigin: String @@ -84,7 +84,7 @@ public final class Passage( Companion.appId = appId ?: getRequiredResourceFromApp(activity, "passage_app_id") clientSecret = getRequiredResourceFromApp(activity, "passage_client_secret") language = getOptionalResourceFromApp(activity, "passage_language") - BASE_PATH_OIDC = "https://$authOrigin" + BASE_PATH_OIDC = "https://$authOrigin" Package_NAME = activity.packageName val usePassageStore = getOptionalResourceFromApp(activity, "use_passage_store") @@ -660,7 +660,6 @@ public final class Passage( } // endregion - // region OIDC Methods /** @@ -689,9 +688,8 @@ public final class Passage( try { val authResult = PassageOIDC.finishOIDC(code) if (authResult != null) handleAuthResult(authResult) - - } catch (e : Exception) { - throw FinishOIDCException.convert(e) + } catch (e: Exception) { + throw FinishOIDCException.convert(e) } } // endregion diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageOIDC.kt index 8fe22e1..e286962 100644 --- a/passage/src/main/java/id/passage/android/PassageOIDC.kt +++ b/passage/src/main/java/id/passage/android/PassageOIDC.kt @@ -26,7 +26,7 @@ internal class PassageOIDC { private const val SECRET_STRING_LENGTH = 32 internal fun openChromeTab( - appId: String, + appId: String, activity: Activity, authUrl: String, ) { @@ -35,19 +35,20 @@ internal class PassageOIDC { val randomString = getRandomString() verifier = randomString val codeChallenge = sha256Hash(randomString) - val newParams = listOf( - "client_id" to appId, - "redirect_uri" to redirectUri, - "state" to state, - "code_challenge" to codeChallenge, - "code_challenge_method" to CODE_CHALLENGE_METHOD, - "scope" to "openid", - "response_type" to "code", - ).joinToString("&") { - (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } - val url = "${authUrl}?${newParams}" + val newParams = + listOf( + "client_id" to appId, + "redirect_uri" to redirectUri, + "state" to state, + "code_challenge" to codeChallenge, + "code_challenge_method" to CODE_CHALLENGE_METHOD, + "scope" to "openid", + "response_type" to "code", + ).joinToString("&") { + (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } + val url = "$authUrl?$newParams" val intent = CustomTabsIntent.Builder().build() intent.launchUrl(activity, Uri.parse(url)) } @@ -75,54 +76,57 @@ internal class PassageOIDC { return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) } - - internal suspend fun finishOIDC(code: String) : AuthResult? { + internal suspend fun finishOIDC(code: String): AuthResult? { val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.Package_NAME}/callback" - var authResult : AuthResult? + var authResult: AuthResult? val client = OkHttpClient() - val moshi = Moshi.Builder() - .build() + val moshi = + Moshi.Builder() + .build() val jsonAdapter = moshi.adapter(OIDCResponse::class.java) val mediaType = "application/json; charset=utf-8".toMediaType() val requestBody = "{\"code\":\"$code\"}".toRequestBody(mediaType) - val params = listOf( - "grant_type" to "authorization_code", - "code" to code, - "client_id" to Passage.appId, - "verifier" to verifier, - "client_secret" to Passage.clientSecret, - "redirect_uri" to redirectUri - ).joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } + val params = + listOf( + "grant_type" to "authorization_code", + "code" to code, + "client_id" to Passage.appId, + "verifier" to verifier, + "client_secret" to Passage.clientSecret, + "redirect_uri" to redirectUri, + ).joinToString("&") { (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } val url = "${Passage.BASE_PATH_OIDC}/token?$params" - val request = Request.Builder() - .url(url) - .post(requestBody) - .build() + val request = + Request.Builder() + .url(url) + .post(requestBody) + .build() withContext(Dispatchers.IO) { client.newCall(request).execute().use { response -> if (!response.isSuccessful) { - if (response.code == 500) + if (response.code == 500) { throw ServerException("Server error : ${response.code} ${response.message}", response.code) + } throw ClientException("Client error : ${response.code} ${response.message}", response.code) } val responseBody = response.body?.string() if (responseBody != null) { val apiResponse = jsonAdapter.fromJson(responseBody)!! - authResult = AuthResult( - authToken = apiResponse.access_token, - redirectUrl = "", - refreshToken = apiResponse.refresh_token, - refreshTokenExpiration = null - ) - - } - else + authResult = + AuthResult( + authToken = apiResponse.access_token, + redirectUrl = "", + refreshToken = apiResponse.refresh_token, + refreshTokenExpiration = null, + ) + } else { throw Exception("Response body is null : ${response.code} ${response.message}") + } } } return authResult From b027db99e08fba77bda239cc6044436cb535973b Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 28 Jun 2024 15:27:35 -0600 Subject: [PATCH 04/22] functions renamed --- passage/src/main/java/id/passage/android/Passage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 8f901d0..ba322c8 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -667,7 +667,7 @@ public final class Passage( * * Authorizes user via a OIDC Login feature. */ - public suspend fun startOIDC() { + public suspend fun hostedAuthStart() { PassageOIDC.openChromeTab( appInfo().id, activity, @@ -684,7 +684,7 @@ public final class Passage( * @throws FinishOIDCException */ - suspend fun finishOIDC(code: String) { + suspend fun hostedAuthFinish(code: String) { try { val authResult = PassageOIDC.finishOIDC(code) if (authResult != null) handleAuthResult(authResult) From 1d09bfa7a59e552918b9dbbe4da17ceec098bd65 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 28 Jun 2024 15:52:51 -0600 Subject: [PATCH 05/22] Ktlint fixes --- .../main/java/id/passage/android/Passage.kt | 4 ++-- .../java/id/passage/android/PassageOIDC.kt | 15 ++++++++++----- .../android/exceptions/FinishOIDCException.kt | 18 ++++++++++++------ 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index ba322c8..bb747ab 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -39,7 +39,7 @@ public final class Passage( internal const val TAG = "Passage" internal var BASE_PATH = "https://auth.passage.id/v1" internal lateinit var BASE_PATH_OIDC: String - internal lateinit var Package_NAME: String + internal lateinit var packageName: String internal lateinit var appId: String internal lateinit var clientSecret: String internal lateinit var authOrigin: String @@ -85,7 +85,7 @@ public final class Passage( clientSecret = getRequiredResourceFromApp(activity, "passage_client_secret") language = getOptionalResourceFromApp(activity, "passage_language") BASE_PATH_OIDC = "https://$authOrigin" - Package_NAME = activity.packageName + packageName = activity.packageName val usePassageStore = getOptionalResourceFromApp(activity, "use_passage_store") if (usePassageStore != "false") { diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageOIDC.kt index e286962..97fb71f 100644 --- a/passage/src/main/java/id/passage/android/PassageOIDC.kt +++ b/passage/src/main/java/id/passage/android/PassageOIDC.kt @@ -30,7 +30,7 @@ internal class PassageOIDC { activity: Activity, authUrl: String, ) { - val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.Package_NAME}/callback" + val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.packageName}/callback" val state = getRandomString() val randomString = getRandomString() verifier = randomString @@ -77,11 +77,12 @@ internal class PassageOIDC { } internal suspend fun finishOIDC(code: String): AuthResult? { - val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.Package_NAME}/callback" + val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.packageName}/callback" var authResult: AuthResult? val client = OkHttpClient() val moshi = - Moshi.Builder() + Moshi + .Builder() .build() val jsonAdapter = moshi.adapter(OIDCResponse::class.java) val mediaType = "application/json; charset=utf-8".toMediaType() @@ -101,7 +102,8 @@ internal class PassageOIDC { val url = "${Passage.BASE_PATH_OIDC}/token?$params" val request = - Request.Builder() + Request + .Builder() .url(url) .post(requestBody) .build() @@ -135,4 +137,7 @@ internal class PassageOIDC { } @JsonClass(generateAdapter = true) -data class OIDCResponse(val access_token: String, val refresh_token: String?) +data class OIDCResponse( + val access_token: String, + val refresh_token: String?, +) diff --git a/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt b/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt index e719aa1..fc2f8dc 100644 --- a/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt +++ b/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt @@ -10,7 +10,10 @@ import id.passage.client.infrastructure.ServerException * * @see FinishOIDCException */ -public open class FinishOIDCException(message: String) : PassageException(message) { +public open class FinishOIDCException( + message: String, +) : PassageException(message) { + // Class body internal companion object { internal fun convert(e: Exception): FinishOIDCException { val message = e.message ?: e.toString() @@ -21,8 +24,8 @@ public open class FinishOIDCException(message: String) : PassageException(messag } } - private fun convertClientException(e: ClientException): FinishOIDCException { - return when (e.statusCode.toString()) { + private fun convertClientException(e: ClientException): FinishOIDCException = + when (e.statusCode.toString()) { "400" -> { FinishOIDCBadRequestException(e.message.toString()) } @@ -30,16 +33,19 @@ public open class FinishOIDCException(message: String) : PassageException(messag FinishOIDCException(e.message.toString()) } } - } } } /** * Thrown when server returns bad request due to the invalid info. */ -public class FinishOIDCBadRequestException(message: String) : FinishOIDCException(message) +public class FinishOIDCBadRequestException( + message: String, +) : FinishOIDCException(message) /** * Thrown when Passage internal server error occurs. */ -public class FinishOIDCServerException(message: String) : FinishOIDCException(message) +public class FinishOIDCServerException( + message: String, +) : FinishOIDCException(message) From d6e0fb798ae1f1d64a20e8a46acca85b35502e97 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 28 Jun 2024 16:31:08 -0600 Subject: [PATCH 06/22] Ktlint fixes --- passage/src/main/java/id/passage/android/Passage.kt | 6 +++--- passage/src/main/java/id/passage/android/PassageOIDC.kt | 9 ++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index bb747ab..be32d08 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -38,7 +38,7 @@ public final class Passage( internal companion object { internal const val TAG = "Passage" internal var BASE_PATH = "https://auth.passage.id/v1" - internal lateinit var BASE_PATH_OIDC: String + internal lateinit var basePathOIDC: String internal lateinit var packageName: String internal lateinit var appId: String internal lateinit var clientSecret: String @@ -84,7 +84,7 @@ public final class Passage( Companion.appId = appId ?: getRequiredResourceFromApp(activity, "passage_app_id") clientSecret = getRequiredResourceFromApp(activity, "passage_client_secret") language = getOptionalResourceFromApp(activity, "passage_language") - BASE_PATH_OIDC = "https://$authOrigin" + basePathOIDC = "https://$authOrigin" packageName = activity.packageName val usePassageStore = getOptionalResourceFromApp(activity, "use_passage_store") @@ -671,7 +671,7 @@ public final class Passage( PassageOIDC.openChromeTab( appInfo().id, activity, - authUrl = "${BASE_PATH_OIDC}/authorize", + authUrl = "$basePathOIDC/authorize", ) } diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageOIDC.kt index 97fb71f..c57807f 100644 --- a/passage/src/main/java/id/passage/android/PassageOIDC.kt +++ b/passage/src/main/java/id/passage/android/PassageOIDC.kt @@ -30,7 +30,7 @@ internal class PassageOIDC { activity: Activity, authUrl: String, ) { - val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.packageName}/callback" + val redirectUri = "${Passage.basePathOIDC}/android/${Passage.packageName}/callback" val state = getRandomString() val randomString = getRandomString() verifier = randomString @@ -44,8 +44,7 @@ internal class PassageOIDC { "code_challenge_method" to CODE_CHALLENGE_METHOD, "scope" to "openid", "response_type" to "code", - ).joinToString("&") { - (key, value) -> + ).joinToString("&") { (key, value) -> "$key=${URLEncoder.encode(value, "UTF-8")}" } val url = "$authUrl?$newParams" @@ -77,7 +76,7 @@ internal class PassageOIDC { } internal suspend fun finishOIDC(code: String): AuthResult? { - val redirectUri = "${Passage.BASE_PATH_OIDC}/android/${Passage.packageName}/callback" + val redirectUri = "${Passage.basePathOIDC}/android/${Passage.packageName}/callback" var authResult: AuthResult? val client = OkHttpClient() val moshi = @@ -100,7 +99,7 @@ internal class PassageOIDC { "$key=${URLEncoder.encode(value, "UTF-8")}" } - val url = "${Passage.BASE_PATH_OIDC}/token?$params" + val url = "${Passage.basePathOIDC}/token?$params" val request = Request .Builder() From 84d18d1995155603fcdff667c33efb7bd457e2ff Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:48:28 -0600 Subject: [PATCH 07/22] OIDC logout feature added --- .../main/java/id/passage/android/Passage.kt | 32 +++- .../java/id/passage/android/PassageOIDC.kt | 143 +++++++++++------- 2 files changed, 109 insertions(+), 66 deletions(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index be32d08..26fcd40 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -41,7 +41,6 @@ public final class Passage( internal lateinit var basePathOIDC: String internal lateinit var packageName: String internal lateinit var appId: String - internal lateinit var clientSecret: String internal lateinit var authOrigin: String internal var language: String? = null @@ -82,9 +81,7 @@ public final class Passage( init { authOrigin = getRequiredResourceFromApp(activity, "passage_auth_origin") Companion.appId = appId ?: getRequiredResourceFromApp(activity, "passage_app_id") - clientSecret = getRequiredResourceFromApp(activity, "passage_client_secret") language = getOptionalResourceFromApp(activity, "passage_language") - basePathOIDC = "https://$authOrigin" packageName = activity.packageName val usePassageStore = getOptionalResourceFromApp(activity, "use_passage_store") @@ -667,11 +664,9 @@ public final class Passage( * * Authorizes user via a OIDC Login feature. */ - public suspend fun hostedAuthStart() { + public fun hostedAuthStart() { PassageOIDC.openChromeTab( - appInfo().id, activity, - authUrl = "$basePathOIDC/authorize", ) } @@ -684,13 +679,34 @@ public final class Passage( * @throws FinishOIDCException */ - suspend fun hostedAuthFinish(code: String) { + suspend fun hostedAuthFinish( + code: String, + clientSecret: String, + state: String, + ) { try { - val authResult = PassageOIDC.finishOIDC(code) + val authResult = PassageOIDC.finishOIDC(activity, code, clientSecret, state) if (authResult != null) handleAuthResult(authResult) } catch (e: Exception) { throw FinishOIDCException.convert(e) } } + + /** + * Logout with OIDC + * + * Logout user via a OIDC Logout feature. + */ + + public suspend fun hostedLogout() { + try { + val idToken = tokenStore.idToken ?: throw Exception("idToken is null") + PassageOIDC.logout(activity, idToken) + tokenStore.clearAndRevokeTokens() + } catch (e: Exception) { + throw FinishOIDCException.convert(e) + } + } + // endregion } diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageOIDC.kt index c57807f..37c8b05 100644 --- a/passage/src/main/java/id/passage/android/PassageOIDC.kt +++ b/passage/src/main/java/id/passage/android/PassageOIDC.kt @@ -3,6 +3,7 @@ package id.passage.android import android.app.Activity import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.Moshi import id.passage.android.model.AuthResult @@ -21,33 +22,32 @@ import java.util.Base64 internal class PassageOIDC { internal companion object { - internal var verifier = "" + private var verifier = "" + private var state = "" private const val CODE_CHALLENGE_METHOD = "S256" private const val SECRET_STRING_LENGTH = 32 + private val basePathOIDC = "https://${Passage.authOrigin}" + private val appId = Passage.appId + private val packageName = Passage.packageName - internal fun openChromeTab( - appId: String, - activity: Activity, - authUrl: String, - ) { - val redirectUri = "${Passage.basePathOIDC}/android/${Passage.packageName}/callback" - val state = getRandomString() + internal fun openChromeTab(activity: Activity) { + val redirectUri = "$basePathOIDC/android/$packageName/callback" + state = getRandomString() val randomString = getRandomString() - verifier = randomString + verifier = getRandomString() val codeChallenge = sha256Hash(randomString) - val newParams = - listOf( - "client_id" to appId, - "redirect_uri" to redirectUri, - "state" to state, - "code_challenge" to codeChallenge, - "code_challenge_method" to CODE_CHALLENGE_METHOD, - "scope" to "openid", - "response_type" to "code", - ).joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } - val url = "$authUrl?$newParams" + val newParams = listOf( + "client_id" to appId, + "redirect_uri" to redirectUri, + "state" to state, + "code_challenge" to codeChallenge, + "code_challenge_method" to CODE_CHALLENGE_METHOD, + "scope" to "openid", + "response_type" to "code", + ).joinToString("&") { (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } + val url = "$basePathOIDC/authorize?$newParams" val intent = CustomTabsIntent.Builder().build() intent.launchUrl(activity, Uri.parse(url)) } @@ -56,9 +56,8 @@ internal class PassageOIDC { val digits = '0'..'9' val upperCaseLetters = 'A'..'Z' val lowerCaseLetters = 'a'..'z' - val characters = - (digits + upperCaseLetters + lowerCaseLetters) - .joinToString("") + val characters = (digits + upperCaseLetters + lowerCaseLetters) + .joinToString("") val random = SecureRandom() val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) for (i in 0 until SECRET_STRING_LENGTH) { @@ -75,37 +74,43 @@ internal class PassageOIDC { return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) } - internal suspend fun finishOIDC(code: String): AuthResult? { - val redirectUri = "${Passage.basePathOIDC}/android/${Passage.packageName}/callback" + internal suspend fun finishOIDC( + activity: Activity, + code: String, + clientSecret: String, + state: String, + ): AuthResult? { + val redirectUri = "$basePathOIDC/android/$packageName/callback" + if (PassageOIDC.state != state) { + throw (Exception("State is Invalid")) + } + var authResult: AuthResult? val client = OkHttpClient() - val moshi = - Moshi - .Builder() - .build() + val moshi = Moshi + .Builder() + .build() val jsonAdapter = moshi.adapter(OIDCResponse::class.java) val mediaType = "application/json; charset=utf-8".toMediaType() val requestBody = "{\"code\":\"$code\"}".toRequestBody(mediaType) - val params = - listOf( - "grant_type" to "authorization_code", - "code" to code, - "client_id" to Passage.appId, - "verifier" to verifier, - "client_secret" to Passage.clientSecret, - "redirect_uri" to redirectUri, - ).joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } + val params = listOf( + "grant_type" to "authorization_code", + "code" to code, + "client_id" to Passage.appId, + "verifier" to verifier, + "client_secret" to clientSecret, + "redirect_uri" to redirectUri, + ).joinToString("&") { (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } - val url = "${Passage.basePathOIDC}/token?$params" - val request = - Request - .Builder() - .url(url) - .post(requestBody) - .build() + val url = "$basePathOIDC/token?$params" + val request = Request + .Builder() + .url(url) + .post(requestBody) + .build() withContext(Dispatchers.IO) { client.newCall(request).execute().use { response -> @@ -118,13 +123,14 @@ internal class PassageOIDC { val responseBody = response.body?.string() if (responseBody != null) { val apiResponse = jsonAdapter.fromJson(responseBody)!! - authResult = - AuthResult( - authToken = apiResponse.access_token, - redirectUrl = "", - refreshToken = apiResponse.refresh_token, - refreshTokenExpiration = null, - ) + authResult = AuthResult( + authToken = apiResponse.accessToken, + redirectUrl = "", + refreshToken = apiResponse.refreshToken, + refreshTokenExpiration = null, + ) + + PassageTokenStore(activity = activity).setIdToken(apiResponse.idToken) } else { throw Exception("Response body is null : ${response.code} ${response.message}") } @@ -132,11 +138,32 @@ internal class PassageOIDC { } return authResult } + + fun logout( + activity: Activity, + idToken: String, + ) { + val redirectUri = "$basePathOIDC/android/$packageName/logout" + verifier = getRandomString() + val url = Uri.parse("$basePathOIDC/logout").buildUpon() + .appendQueryParameter("id_token_hint", idToken) + .appendQueryParameter("client_id", appId) + .appendQueryParameter("state", verifier) + .appendQueryParameter("post_logout_redirect_uri", redirectUri) + .build() + + val customTabsIntent = CustomTabsIntent.Builder().build() + customTabsIntent.launchUrl(activity, url) + } } } @JsonClass(generateAdapter = true) data class OIDCResponse( - val access_token: String, - val refresh_token: String?, + @Json(name = "access_token") + val accessToken: String, + @Json(name = "refresh_token") + val refreshToken: String?, + @Json(name = "id_token") + val idToken: String, ) From ac0f5e955d7378823b88b2c10a158e71f77c2ced Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:51:46 -0600 Subject: [PATCH 08/22] Logout function renamed --- passage/src/main/java/id/passage/android/Passage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 26fcd40..2a042b7 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -698,7 +698,7 @@ public final class Passage( * Logout user via a OIDC Logout feature. */ - public suspend fun hostedLogout() { + public suspend fun hostedAuthLogout() { try { val idToken = tokenStore.idToken ?: throw Exception("idToken is null") PassageOIDC.logout(activity, idToken) From 6993a360881fe91b4eec1b410363416730ba33a0 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Thu, 4 Jul 2024 13:03:15 -0600 Subject: [PATCH 09/22] Ktlint fixes --- .../java/id/passage/android/PassageOIDC.kt | 91 ++++++++++--------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageOIDC.kt index 37c8b05..9d2e18a 100644 --- a/passage/src/main/java/id/passage/android/PassageOIDC.kt +++ b/passage/src/main/java/id/passage/android/PassageOIDC.kt @@ -36,17 +36,18 @@ internal class PassageOIDC { val randomString = getRandomString() verifier = getRandomString() val codeChallenge = sha256Hash(randomString) - val newParams = listOf( - "client_id" to appId, - "redirect_uri" to redirectUri, - "state" to state, - "code_challenge" to codeChallenge, - "code_challenge_method" to CODE_CHALLENGE_METHOD, - "scope" to "openid", - "response_type" to "code", - ).joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } + val newParams = + listOf( + "client_id" to appId, + "redirect_uri" to redirectUri, + "state" to state, + "code_challenge" to codeChallenge, + "code_challenge_method" to CODE_CHALLENGE_METHOD, + "scope" to "openid", + "response_type" to "code", + ).joinToString("&") { (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } val url = "$basePathOIDC/authorize?$newParams" val intent = CustomTabsIntent.Builder().build() intent.launchUrl(activity, Uri.parse(url)) @@ -87,30 +88,33 @@ internal class PassageOIDC { var authResult: AuthResult? val client = OkHttpClient() - val moshi = Moshi - .Builder() - .build() + val moshi = + Moshi + .Builder() + .build() val jsonAdapter = moshi.adapter(OIDCResponse::class.java) val mediaType = "application/json; charset=utf-8".toMediaType() val requestBody = "{\"code\":\"$code\"}".toRequestBody(mediaType) - val params = listOf( - "grant_type" to "authorization_code", - "code" to code, - "client_id" to Passage.appId, - "verifier" to verifier, - "client_secret" to clientSecret, - "redirect_uri" to redirectUri, - ).joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } + val params = + listOf( + "grant_type" to "authorization_code", + "code" to code, + "client_id" to Passage.appId, + "verifier" to verifier, + "client_secret" to clientSecret, + "redirect_uri" to redirectUri, + ).joinToString("&") { (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } val url = "$basePathOIDC/token?$params" - val request = Request - .Builder() - .url(url) - .post(requestBody) - .build() + val request = + Request + .Builder() + .url(url) + .post(requestBody) + .build() withContext(Dispatchers.IO) { client.newCall(request).execute().use { response -> @@ -123,12 +127,13 @@ internal class PassageOIDC { val responseBody = response.body?.string() if (responseBody != null) { val apiResponse = jsonAdapter.fromJson(responseBody)!! - authResult = AuthResult( - authToken = apiResponse.accessToken, - redirectUrl = "", - refreshToken = apiResponse.refreshToken, - refreshTokenExpiration = null, - ) + authResult = + AuthResult( + authToken = apiResponse.accessToken, + redirectUrl = "", + refreshToken = apiResponse.refreshToken, + refreshTokenExpiration = null, + ) PassageTokenStore(activity = activity).setIdToken(apiResponse.idToken) } else { @@ -145,12 +150,14 @@ internal class PassageOIDC { ) { val redirectUri = "$basePathOIDC/android/$packageName/logout" verifier = getRandomString() - val url = Uri.parse("$basePathOIDC/logout").buildUpon() - .appendQueryParameter("id_token_hint", idToken) - .appendQueryParameter("client_id", appId) - .appendQueryParameter("state", verifier) - .appendQueryParameter("post_logout_redirect_uri", redirectUri) - .build() + val url = + Uri.parse("$basePathOIDC/logout") + .buildUpon() + .appendQueryParameter("id_token_hint", idToken) + .appendQueryParameter("client_id", appId) + .appendQueryParameter("state", verifier) + .appendQueryParameter("post_logout_redirect_uri", redirectUri) + .build() val customTabsIntent = CustomTabsIntent.Builder().build() customTabsIntent.launchUrl(activity, url) @@ -166,4 +173,4 @@ data class OIDCResponse( val refreshToken: String?, @Json(name = "id_token") val idToken: String, -) +) \ No newline at end of file From 8235b9701e6c35ceafa4de27859476d80878a748 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 5 Jul 2024 16:57:06 -0600 Subject: [PATCH 10/22] OIDC tests added --- .../passage/android/IntegrationTestConfig.kt | 1 + .../java/id/passage/android/OIDCTests.kt | 114 ++++++++++++++++++ passage/src/debug/AndroidManifest.xml | 16 ++- .../java/id/passage/android/TestActivity.kt | 24 +++- passage/src/debug/res/values/strings.xml | 3 +- passage/src/debug/res/values/styles.xml | 5 + .../id/passage/android/PassageTokenStore.kt | 18 ++- 7 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 passage/src/androidTest/java/id/passage/android/OIDCTests.kt create mode 100644 passage/src/debug/res/values/styles.xml diff --git a/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt b/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt index ee14bc5..4afaf9a 100644 --- a/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt +++ b/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt @@ -7,6 +7,7 @@ internal class IntegrationTestConfig { companion object { const val API_BASE_URL = "https://auth-uat.passage.dev/v1" const val APP_ID_OTP = "Ezbk6fSdx9pNQ7v7UbVEnzeC" + const val APP_ID_OIDC = "2ZWhX75KpwKKVdr4gxiZph9m" const val APP_ID_MAGIC_LINK = "Pea2GdtBHN3esylK4ZRlF19U" const val WAIT_TIME_MILLISECONDS: Long = 8000 const val EXISTING_USER_EMAIL_OTP = "authentigator+1716916054778@ncor7c1m.mailosaur.net" diff --git a/passage/src/androidTest/java/id/passage/android/OIDCTests.kt b/passage/src/androidTest/java/id/passage/android/OIDCTests.kt new file mode 100644 index 0000000..ad4a235 --- /dev/null +++ b/passage/src/androidTest/java/id/passage/android/OIDCTests.kt @@ -0,0 +1,114 @@ +package id.passage.android + +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.google.common.truth.Truth.assertThat +import id.passage.android.IntegrationTestConfig.Companion.API_BASE_URL +import id.passage.android.IntegrationTestConfig.Companion.APP_ID_OIDC +import id.passage.android.IntegrationTestConfig.Companion.EXISTING_USER_EMAIL_OTP +import id.passage.android.exceptions.FinishOIDCException +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.fail +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class OIDCTests { + private lateinit var passage: Passage + + @Before + fun setup() { + Intents.init() + activityRule?.scenario?.onActivity { activity -> + activity?.let { + passage = Passage(it, APP_ID_OIDC) + passage.overrideBasePath(API_BASE_URL) + } + } + } + + @After + fun teardown() = + runTest { + Intents.release() + } + + @get:Rule + var activityRule: ActivityScenarioRule? = + ActivityScenarioRule( + TestActivity::class.java, + ) + + @Test + fun testHostedAuthStart(): Unit = + runBlocking { + try { + hostedAuthLogin() + val user = passage.getCurrentUser() + assertNotNull(user) + } catch (e: Exception) { + fail("Test failed due to unexpected exception: ${e.message}") + } finally { + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + } + } + + private suspend fun hostedAuthLogin() { + passage.hostedAuthStart() + delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val loginField = device.findObject(UiSelector().className("android.widget.EditText").instance(0)) + loginField.setText(EXISTING_USER_EMAIL_OTP) + val continueButton = device.findObject(UiSelector().className("android.widget.Button").text("Continue")) + continueButton.click() + delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) + val otpCode = MailosaurAPIClient.getMostRecentOneTimePasscode() // Replace with the actual OTP code + + otpCode.forEachIndexed { index, char -> + val otpField = device.findObject(UiSelector().className("android.widget.EditText").instance(index)) + otpField.click() + otpField.setText(char.toString()) + Thread.sleep(200) + } + + delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) + val skipButton = device.findObject(UiSelector().className("android.widget.Button").text("Skip")) + skipButton.click() + } + + @Test + fun testHostedLogout(): Unit = + runBlocking { + try { + hostedAuthLogin() + passage.hostedAuthLogout() + val user = passage.getCurrentUser() + assertNull(user) + } catch (e: Exception) { + fail("Test failed due to unexpected exception: ${e.message}") + } + } + + @Test + fun testFinishAuthorizationInvalidRequest() = + runTest { + try { + val invalidAuthCode = "INVALID_AUTH_CODE" + passage.hostedAuthFinish(invalidAuthCode, "", "") + fail("Test should throw FinishOIDCAuthenticationInvalidRequestException") + } catch (e: Exception) { + assertThat(e is FinishOIDCException) + } + } +} diff --git a/passage/src/debug/AndroidManifest.xml b/passage/src/debug/AndroidManifest.xml index 7d096ac..67f3810 100644 --- a/passage/src/debug/AndroidManifest.xml +++ b/passage/src/debug/AndroidManifest.xml @@ -4,6 +4,20 @@ + android:launchMode="singleInstance" + android:theme="@style/AppTheme"> + + + + + + + + + + + + + diff --git a/passage/src/debug/java/id/passage/android/TestActivity.kt b/passage/src/debug/java/id/passage/android/TestActivity.kt index f3e23a4..6e47af6 100644 --- a/passage/src/debug/java/id/passage/android/TestActivity.kt +++ b/passage/src/debug/java/id/passage/android/TestActivity.kt @@ -1,11 +1,31 @@ package id.passage.android -import android.app.Activity +import android.content.Intent import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.runBlocking + +class TestActivity : AppCompatActivity() { + private lateinit var passage: Passage -internal class TestActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_test) + passage = Passage(this) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val authCode = intent.data?.getQueryParameter("code") ?: "" + val state = intent.data?.getQueryParameter("state") ?: "" + if (authCode.isNotEmpty()) { + runBlocking { + try { + passage.hostedAuthFinish(authCode, "JkXmvBNPFTL0Zb7Ya7W4wc0o7cOi9o8K", state) + } catch (e: Exception) { + // Handle any exceptions + } + } + } } } diff --git a/passage/src/debug/res/values/strings.xml b/passage/src/debug/res/values/strings.xml index 1dd4002..deeaa6e 100644 --- a/passage/src/debug/res/values/strings.xml +++ b/passage/src/debug/res/values/strings.xml @@ -2,7 +2,8 @@ Test App - try-uat.passage.dev + 2ZWhX75KpwKKVdr4gxiZph9m + fragile-greenyellow-bat.withpassage-uat.com [{ \"include\": \"https://@string/passage_auth_origin/.well-known/assetlinks.json\" diff --git a/passage/src/debug/res/values/styles.xml b/passage/src/debug/res/values/styles.xml new file mode 100644 index 0000000..85a6ed8 --- /dev/null +++ b/passage/src/debug/res/values/styles.xml @@ -0,0 +1,5 @@ + + + + diff --git a/passage/src/main/java/id/passage/android/PassageTokenStore.kt b/passage/src/main/java/id/passage/android/PassageTokenStore.kt index 07ebbb1..7901287 100644 --- a/passage/src/main/java/id/passage/android/PassageTokenStore.kt +++ b/passage/src/main/java/id/passage/android/PassageTokenStore.kt @@ -13,15 +13,19 @@ import kotlinx.coroutines.launch import java.lang.Exception @Suppress("unused", "RedundantVisibilityModifier", "RedundantModalityModifier") -public final class PassageTokenStore(activity: Activity) { +public final class PassageTokenStore( + activity: Activity, +) { private companion object { private const val PASSAGE_SHARED_PREFERENCES = "PASSAGE_SHARED_PREFERENCES" private const val PASSAGE_AUTH_TOKEN = "PASSAGE_AUTH_TOKEN" private const val PASSAGE_REFRESH_TOKEN = "PASSAGE_REFRESH_TOKEN" + private const val PASSAGE_ID_TOKEN = "PASSAGE_ID_TOKEN" } private val masterKey: MasterKey = - MasterKey.Builder(activity) + MasterKey + .Builder(activity) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -37,6 +41,9 @@ public final class PassageTokenStore(activity: Activity) { public val authToken: String? get() = sharedPreferences.getString(PASSAGE_AUTH_TOKEN, null) + public val idToken: String? + get() = sharedPreferences.getString(PASSAGE_ID_TOKEN, null) + internal val refreshToken: String? get() = sharedPreferences.getString(PASSAGE_REFRESH_TOKEN, null) @@ -56,6 +63,13 @@ public final class PassageTokenStore(activity: Activity) { return authResult.authToken } + public fun setIdToken(token: String?) { + with(sharedPreferences.edit()) { + putString(PASSAGE_ID_TOKEN, token) + apply() + } + } + private fun setAuthToken(token: String?) { with(sharedPreferences.edit()) { putString(PASSAGE_AUTH_TOKEN, token) From 1a60aaa79f66a5b2f53e23783383dc009c6c2555 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Thu, 11 Jul 2024 15:51:55 -0600 Subject: [PATCH 11/22] Clean ups: More Exceptions added - renaming classes --- .../android/{OIDCTests.kt => HostedTests.kt} | 6 +-- .../main/java/id/passage/android/Passage.kt | 36 ++++++------- .../{PassageOIDC.kt => PassageHosted.kt} | 7 +-- .../android/exceptions/FinishOIDCException.kt | 51 ------------------- .../exceptions/HostedAuthorizationError.kt | 47 +++++++++++++++++ 5 files changed, 72 insertions(+), 75 deletions(-) rename passage/src/androidTest/java/id/passage/android/{OIDCTests.kt => HostedTests.kt} (96%) rename passage/src/main/java/id/passage/android/{PassageOIDC.kt => PassageHosted.kt} (97%) delete mode 100644 passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt create mode 100644 passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt diff --git a/passage/src/androidTest/java/id/passage/android/OIDCTests.kt b/passage/src/androidTest/java/id/passage/android/HostedTests.kt similarity index 96% rename from passage/src/androidTest/java/id/passage/android/OIDCTests.kt rename to passage/src/androidTest/java/id/passage/android/HostedTests.kt index ad4a235..857858b 100644 --- a/passage/src/androidTest/java/id/passage/android/OIDCTests.kt +++ b/passage/src/androidTest/java/id/passage/android/HostedTests.kt @@ -10,7 +10,7 @@ import com.google.common.truth.Truth.assertThat import id.passage.android.IntegrationTestConfig.Companion.API_BASE_URL import id.passage.android.IntegrationTestConfig.Companion.APP_ID_OIDC import id.passage.android.IntegrationTestConfig.Companion.EXISTING_USER_EMAIL_OTP -import id.passage.android.exceptions.FinishOIDCException +import id.passage.android.exceptions.HostedAuthorizationError import junit.framework.TestCase.assertNotNull import junit.framework.TestCase.assertNull import junit.framework.TestCase.fail @@ -24,7 +24,7 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -internal class OIDCTests { +internal class HostedTests { private lateinit var passage: Passage @Before @@ -108,7 +108,7 @@ internal class OIDCTests { passage.hostedAuthFinish(invalidAuthCode, "", "") fail("Test should throw FinishOIDCAuthenticationInvalidRequestException") } catch (e: Exception) { - assertThat(e is FinishOIDCException) + assertThat(e is HostedAuthorizationError) } } } diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 2a042b7..c9fd69a 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -660,23 +660,26 @@ public final class Passage( // region OIDC Methods /** - * Authorize with OIDC + * Authentication Method for Hosted Apps * - * Authorizes user via a OIDC Login feature. + * If your Passage app is Hosted, use this method to register and log in your user. + * This method will open up a Passage login experience on a Chrome tab. */ public fun hostedAuthStart() { - PassageOIDC.openChromeTab( + PassageHosted.openChromeTab( activity, ) } /** - * Finish OIDC login + * Finish Hosted Auth for Hosted Apps * - * Finishes a OIDC login/sign up by exchanging the auth code for Passage tokens. - * @param code The code returned from the OIDC login. - * @return PassageAuthResult - * @throws FinishOIDCException + * This method completes the hosted authentication process by exchanging the provided authorization code for Passage tokens. + * + * @param code The code returned from app link redirect to your activity. + * @param clientSecret You hosted app's client secret, found in Passage Console's OIDC Settings. + * @param state The state returned from app link redirect to your activity. + * @throws HostedAuthorizationError */ suspend fun hostedAuthFinish( @@ -685,27 +688,24 @@ public final class Passage( state: String, ) { try { - val authResult = PassageOIDC.finishOIDC(activity, code, clientSecret, state) + val authResult = PassageHosted.finishOIDC(activity, code, clientSecret, state) if (authResult != null) handleAuthResult(authResult) } catch (e: Exception) { - throw FinishOIDCException.convert(e) + throw HostedAuthorizationError.convert(e) } } /** - * Logout with OIDC + * Logout Method for Hosted Apps * - * Logout user via a OIDC Logout feature. + * If your Passage app is Hosted, use this method to log out your user. This method will briefly open up a web view where it will log out the + * @throws HostedLogoutException */ public suspend fun hostedAuthLogout() { - try { - val idToken = tokenStore.idToken ?: throw Exception("idToken is null") - PassageOIDC.logout(activity, idToken) + val idToken = tokenStore.idToken ?: throw HostedLogoutException("Can't Logout - Missing Id Token") + PassageHosted.logout(activity, idToken) tokenStore.clearAndRevokeTokens() - } catch (e: Exception) { - throw FinishOIDCException.convert(e) - } } // endregion diff --git a/passage/src/main/java/id/passage/android/PassageOIDC.kt b/passage/src/main/java/id/passage/android/PassageHosted.kt similarity index 97% rename from passage/src/main/java/id/passage/android/PassageOIDC.kt rename to passage/src/main/java/id/passage/android/PassageHosted.kt index 9d2e18a..b486946 100644 --- a/passage/src/main/java/id/passage/android/PassageOIDC.kt +++ b/passage/src/main/java/id/passage/android/PassageHosted.kt @@ -6,6 +6,7 @@ import androidx.browser.customtabs.CustomTabsIntent import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.Moshi +import id.passage.android.exceptions.HostedAuthorizationError import id.passage.android.model.AuthResult import id.passage.client.infrastructure.ClientException import id.passage.client.infrastructure.ServerException @@ -20,7 +21,7 @@ import java.security.MessageDigest import java.security.SecureRandom import java.util.Base64 -internal class PassageOIDC { +internal class PassageHosted { internal companion object { private var verifier = "" private var state = "" @@ -82,8 +83,8 @@ internal class PassageOIDC { state: String, ): AuthResult? { val redirectUri = "$basePathOIDC/android/$packageName/callback" - if (PassageOIDC.state != state) { - throw (Exception("State is Invalid")) + if (PassageHosted.state != state) { + throw HostedAuthorizationError("State is Invalid") } var authResult: AuthResult? diff --git a/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt b/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt deleted file mode 100644 index fc2f8dc..0000000 --- a/passage/src/main/java/id/passage/android/exceptions/FinishOIDCException.kt +++ /dev/null @@ -1,51 +0,0 @@ -@file:Suppress("RedundantVisibilityModifier") - -package id.passage.android.exceptions - -import id.passage.client.infrastructure.ClientException -import id.passage.client.infrastructure.ServerException - -/** - * Thrown when there's an error finishing OIDC login/sign up - * - * @see FinishOIDCException - */ -public open class FinishOIDCException( - message: String, -) : PassageException(message) { - // Class body - internal companion object { - internal fun convert(e: Exception): FinishOIDCException { - val message = e.message ?: e.toString() - return when (e) { - is ClientException -> convertClientException(e) - is ServerException -> FinishOIDCServerException(message) - else -> FinishOIDCException(message) - } - } - - private fun convertClientException(e: ClientException): FinishOIDCException = - when (e.statusCode.toString()) { - "400" -> { - FinishOIDCBadRequestException(e.message.toString()) - } - else -> { - FinishOIDCException(e.message.toString()) - } - } - } -} - -/** - * Thrown when server returns bad request due to the invalid info. - */ -public class FinishOIDCBadRequestException( - message: String, -) : FinishOIDCException(message) - -/** - * Thrown when Passage internal server error occurs. - */ -public class FinishOIDCServerException( - message: String, -) : FinishOIDCException(message) diff --git a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt new file mode 100644 index 0000000..aeb4066 --- /dev/null +++ b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt @@ -0,0 +1,47 @@ +@file:Suppress("RedundantVisibilityModifier") + +package id.passage.android.exceptions + +import id.passage.client.infrastructure.ClientException +import id.passage.client.infrastructure.ServerException + +/** + * Thrown when there's an error with Hosted Auth + * + * @see HostedAuthorizationError + */ +public open class HostedAuthorizationError( + message: String, +) : PassageException(message) { + // Class body + internal companion object { + internal fun convert(e: Exception): HostedAuthorizationError { + val message = e.message ?: e.toString() + return when (e) { + is ClientException -> FinishOIDCServerException(message) + is ServerException -> FinishHostedBadRequestException(message) + else -> HostedAuthorizationError(message) + } + } + } +} + +/** + * Thrown when server returns bad request due to the invalid info. + */ +public class FinishHostedBadRequestException( + message: String, +) : HostedAuthorizationError(message) + +/** + * Thrown when Passage internal server error occurs. + */ +public class FinishOIDCServerException( + message: String, +) : HostedAuthorizationError(message) + + +/** + * Thrown when a error occurs During Hosted Logout. + */ +public class HostedLogoutException(message: String) : HostedAuthorizationError(message) From 0f83e0594ebee4ef1326c06468025b5c32fc0ead Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 12 Jul 2024 14:16:10 -0600 Subject: [PATCH 12/22] Comments addressed - tests updated --- .../java/id/passage/android/HostedTests.kt | 32 +++++++++-- passage/src/debug/AndroidManifest.xml | 1 + .../main/java/id/passage/android/Passage.kt | 32 ++++++++--- .../java/id/passage/android/PassageHosted.kt | 55 +++++-------------- .../java/id/passage/android/PassageSocial.kt | 32 +---------- .../src/main/java/id/passage/android/Utils.kt | 36 ++++++++++++ .../exceptions/HostedAuthorizationError.kt | 4 +- 7 files changed, 107 insertions(+), 85 deletions(-) create mode 100644 passage/src/main/java/id/passage/android/Utils.kt diff --git a/passage/src/androidTest/java/id/passage/android/HostedTests.kt b/passage/src/androidTest/java/id/passage/android/HostedTests.kt index 857858b..d761ed7 100644 --- a/passage/src/androidTest/java/id/passage/android/HostedTests.kt +++ b/passage/src/androidTest/java/id/passage/android/HostedTests.kt @@ -68,10 +68,26 @@ internal class HostedTests { passage.hostedAuthStart() delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val loginField = device.findObject(UiSelector().className("android.widget.EditText").instance(0)) - loginField.setText(EXISTING_USER_EMAIL_OTP) - val continueButton = device.findObject(UiSelector().className("android.widget.Button").text("Continue")) - continueButton.click() + // Handle the Chrome welcome screen + val addAccountButton = device.findObject(UiSelector().className("android.widget.Button").text("Add account to device")) + val useWithoutAccountButton = device.findObject(UiSelector().className("android.widget.Button").text("Use without an account")) + if (addAccountButton.exists()) { + useWithoutAccountButton.click() + delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) + } + // Check if the user is on the login screen with the email field + val emailField = device.findObject(UiSelector().className("android.widget.EditText").instance(0)) + if (emailField.exists()) { + emailField.setText(EXISTING_USER_EMAIL_OTP) + val nextButton = device.findObject(UiSelector().className("android.widget.Button").text("Continue")) + nextButton.click() + } else { + // User is already logged in, click Continue button + val continueButton = device.findObject(UiSelector().className("android.widget.Button").text("Continue")) + if (continueButton.exists()) { + continueButton.click() + } + } delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) val otpCode = MailosaurAPIClient.getMostRecentOneTimePasscode() // Replace with the actual OTP code @@ -84,14 +100,18 @@ internal class HostedTests { delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) val skipButton = device.findObject(UiSelector().className("android.widget.Button").text("Skip")) - skipButton.click() + if (skipButton.exists()) { + skipButton.click() + delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) + } } @Test fun testHostedLogout(): Unit = runBlocking { try { - hostedAuthLogin() + val alreadyAuthenticated = passage.getCurrentUser() + if (alreadyAuthenticated == null) hostedAuthLogin() passage.hostedAuthLogout() val user = passage.getCurrentUser() assertNull(user) diff --git a/passage/src/debug/AndroidManifest.xml b/passage/src/debug/AndroidManifest.xml index 67f3810..4b60b56 100644 --- a/passage/src/debug/AndroidManifest.xml +++ b/passage/src/debug/AndroidManifest.xml @@ -17,6 +17,7 @@ + diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index c9fd69a..65d7770 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -15,6 +15,7 @@ import id.passage.android.api.UsersAPI import id.passage.android.exceptions.* import id.passage.android.model.ActivateMagicLinkRequest import id.passage.android.model.ActivateOneTimePasscodeRequest +import id.passage.android.model.AuthResult import id.passage.android.model.AuthenticatorAttachment import id.passage.android.model.GetMagicLinkStatusRequest import id.passage.android.model.LoginMagicLinkRequest @@ -38,7 +39,6 @@ public final class Passage( internal companion object { internal const val TAG = "Passage" internal var BASE_PATH = "https://auth.passage.id/v1" - internal lateinit var basePathOIDC: String internal lateinit var packageName: String internal lateinit var appId: String internal lateinit var authOrigin: String @@ -655,6 +655,20 @@ public final class Passage( if (!isUsingTokenStore) return tokenStore.setTokens(authResult) } + + /** + * Updates the Passage Token Store with the given ID token. + * + * This method should be called whenever a user utilizes Hosted Auth to ensure the + * isToken is updated in the Passage Token Store, if applicable. + * + * @param idToken The ID token to be handled. + */ + + private fun handleIdToken(idToken: String) { + if (!isUsingTokenStore) return + tokenStore.setIdToken(idToken) + } // endregion // region OIDC Methods @@ -686,10 +700,14 @@ public final class Passage( code: String, clientSecret: String, state: String, - ) { + ) : Pair { try { - val authResult = PassageHosted.finishOIDC(activity, code, clientSecret, state) - if (authResult != null) handleAuthResult(authResult) + val finishHostedAuthResult = PassageHosted.finishHostedAuth(code, clientSecret, state) + finishHostedAuthResult.let { (authResult, idToken) -> + handleAuthResult(authResult) + handleIdToken(idToken) + } + return finishHostedAuthResult } catch (e: Exception) { throw HostedAuthorizationError.convert(e) } @@ -703,9 +721,9 @@ public final class Passage( */ public suspend fun hostedAuthLogout() { - val idToken = tokenStore.idToken ?: throw HostedLogoutException("Can't Logout - Missing Id Token") - PassageHosted.logout(activity, idToken) - tokenStore.clearAndRevokeTokens() + val idToken = tokenStore.idToken ?: throw HostedLogoutException("Can't Logout - Missing Id Token") + PassageHosted.logout(activity, idToken) + tokenStore.clearAndRevokeTokens() } // endregion diff --git a/passage/src/main/java/id/passage/android/PassageHosted.kt b/passage/src/main/java/id/passage/android/PassageHosted.kt index b486946..0712908 100644 --- a/passage/src/main/java/id/passage/android/PassageHosted.kt +++ b/passage/src/main/java/id/passage/android/PassageHosted.kt @@ -17,26 +17,22 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.URLEncoder -import java.security.MessageDigest -import java.security.SecureRandom -import java.util.Base64 internal class PassageHosted { internal companion object { private var verifier = "" private var state = "" private const val CODE_CHALLENGE_METHOD = "S256" - private const val SECRET_STRING_LENGTH = 32 private val basePathOIDC = "https://${Passage.authOrigin}" private val appId = Passage.appId private val packageName = Passage.packageName internal fun openChromeTab(activity: Activity) { val redirectUri = "$basePathOIDC/android/$packageName/callback" - state = getRandomString() - val randomString = getRandomString() - verifier = getRandomString() - val codeChallenge = sha256Hash(randomString) + state = Utils.getRandomString() + val randomString = Utils.getRandomString() + verifier = Utils.getRandomString() + val codeChallenge = Utils.sha256Hash(randomString) val newParams = listOf( "client_id" to appId, @@ -54,40 +50,18 @@ internal class PassageHosted { intent.launchUrl(activity, Uri.parse(url)) } - private fun getRandomString(): String { - val digits = '0'..'9' - val upperCaseLetters = 'A'..'Z' - val lowerCaseLetters = 'a'..'z' - val characters = (digits + upperCaseLetters + lowerCaseLetters) - .joinToString("") - val random = SecureRandom() - val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) - for (i in 0 until SECRET_STRING_LENGTH) { - val randomIndex = random.nextInt(characters.length) - stringBuilder.append(characters[randomIndex]) - } - return stringBuilder.toString() - } - - private fun sha256Hash(randomString: String): String { - val bytes = randomString.toByteArray() - val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(bytes) - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) - } - - internal suspend fun finishOIDC( - activity: Activity, + internal suspend fun finishHostedAuth( code: String, clientSecret: String, state: String, - ): AuthResult? { + ): Pair { val redirectUri = "$basePathOIDC/android/$packageName/callback" if (PassageHosted.state != state) { throw HostedAuthorizationError("State is Invalid") } - var authResult: AuthResult? + var authResult: AuthResult + var idToken : String val client = OkHttpClient() val moshi = Moshi @@ -135,14 +109,13 @@ internal class PassageHosted { refreshToken = apiResponse.refreshToken, refreshTokenExpiration = null, ) - - PassageTokenStore(activity = activity).setIdToken(apiResponse.idToken) + idToken = apiResponse.idToken } else { throw Exception("Response body is null : ${response.code} ${response.message}") } } } - return authResult + return Pair(authResult, idToken) } fun logout( @@ -150,9 +123,9 @@ internal class PassageHosted { idToken: String, ) { val redirectUri = "$basePathOIDC/android/$packageName/logout" - verifier = getRandomString() - val url = - Uri.parse("$basePathOIDC/logout") + verifier = Utils.getRandomString() + val url = Uri + .parse("$basePathOIDC/logout") .buildUpon() .appendQueryParameter("id_token_hint", idToken) .appendQueryParameter("client_id", appId) @@ -174,4 +147,4 @@ data class OIDCResponse( val refreshToken: String?, @Json(name = "id_token") val idToken: String, -) \ No newline at end of file +) diff --git a/passage/src/main/java/id/passage/android/PassageSocial.kt b/passage/src/main/java/id/passage/android/PassageSocial.kt index 9ed41b3..133dab8 100644 --- a/passage/src/main/java/id/passage/android/PassageSocial.kt +++ b/passage/src/main/java/id/passage/android/PassageSocial.kt @@ -5,15 +5,11 @@ import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent import id.passage.android.model.OAuth2ConnectionType import java.net.URLEncoder -import java.security.MessageDigest -import java.security.SecureRandom -import java.util.Base64 internal class PassageSocial { internal companion object { internal var verifier = "" private const val CODE_CHALLENGE_METHOD = "S256" - private const val SECRET_STRING_LENGTH = 32 internal fun openChromeTab( connection: OAuth2ConnectionType, @@ -22,10 +18,10 @@ internal class PassageSocial { authUrl: String, ) { val redirectURI = "https://$authOrigin" - val state = getRandomString() - val randomString = getRandomString() + val state = Utils.getRandomString() + val randomString = Utils.getRandomString() verifier = randomString - val codeChallenge = sha256Hash(randomString) + val codeChallenge = Utils.sha256Hash(randomString) val params = listOf( "redirect_uri" to redirectURI, @@ -42,27 +38,5 @@ internal class PassageSocial { intent.launchUrl(activity, Uri.parse(url)) } - private fun getRandomString(): String { - val digits = '0'..'9' - val upperCaseLetters = 'A'..'Z' - val lowerCaseLetters = 'a'..'z' - val characters = - (digits + upperCaseLetters + lowerCaseLetters) - .joinToString("") - val random = SecureRandom() - val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) - for (i in 0 until SECRET_STRING_LENGTH) { - val randomIndex = random.nextInt(characters.length) - stringBuilder.append(characters[randomIndex]) - } - return stringBuilder.toString() - } - - private fun sha256Hash(randomString: String): String { - val bytes = randomString.toByteArray() - val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(bytes) - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) - } } } diff --git a/passage/src/main/java/id/passage/android/Utils.kt b/passage/src/main/java/id/passage/android/Utils.kt new file mode 100644 index 0000000..cec411b --- /dev/null +++ b/passage/src/main/java/id/passage/android/Utils.kt @@ -0,0 +1,36 @@ +package id.passage.android + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +class Utils { + companion object { + private const val SECRET_STRING_LENGTH = 32 + + fun getRandomString(): String { + val digits = '0'..'9' + val upperCaseLetters = 'A'..'Z' + val lowerCaseLetters = 'a'..'z' + val characters = (digits + + upperCaseLetters + + lowerCaseLetters) + .joinToString("") + val random = SecureRandom() + val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) + for (i in 0 until SECRET_STRING_LENGTH) { + val randomIndex = random.nextInt(characters.length) + stringBuilder.append(characters[randomIndex]) + } + return stringBuilder.toString() + } + + fun sha256Hash(randomString: String): String { + val bytes = randomString.toByteArray() + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + } + + } +} diff --git a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt index aeb4066..b355e8b 100644 --- a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt +++ b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt @@ -18,7 +18,7 @@ public open class HostedAuthorizationError( internal fun convert(e: Exception): HostedAuthorizationError { val message = e.message ?: e.toString() return when (e) { - is ClientException -> FinishOIDCServerException(message) + is ClientException -> FinishHostedServerException(message) is ServerException -> FinishHostedBadRequestException(message) else -> HostedAuthorizationError(message) } @@ -36,7 +36,7 @@ public class FinishHostedBadRequestException( /** * Thrown when Passage internal server error occurs. */ -public class FinishOIDCServerException( +public class FinishHostedServerException( message: String, ) : HostedAuthorizationError(message) From cfcb736b24ac9a8c374ecd17a4e2476e7417439f Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 12 Jul 2024 14:28:11 -0600 Subject: [PATCH 13/22] Ktlint fixes --- .../main/java/id/passage/android/Passage.kt | 2 +- .../java/id/passage/android/PassageHosted.kt | 19 +++++++++---------- .../src/main/java/id/passage/android/Utils.kt | 7 ++++--- .../exceptions/HostedAuthorizationError.kt | 5 +++-- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 65d7770..2697e12 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -700,7 +700,7 @@ public final class Passage( code: String, clientSecret: String, state: String, - ) : Pair { + ): Pair { try { val finishHostedAuthResult = PassageHosted.finishHostedAuth(code, clientSecret, state) finishHostedAuthResult.let { (authResult, idToken) -> diff --git a/passage/src/main/java/id/passage/android/PassageHosted.kt b/passage/src/main/java/id/passage/android/PassageHosted.kt index 0712908..1865158 100644 --- a/passage/src/main/java/id/passage/android/PassageHosted.kt +++ b/passage/src/main/java/id/passage/android/PassageHosted.kt @@ -59,9 +59,8 @@ internal class PassageHosted { if (PassageHosted.state != state) { throw HostedAuthorizationError("State is Invalid") } - var authResult: AuthResult - var idToken : String + var idToken: String val client = OkHttpClient() val moshi = Moshi @@ -124,14 +123,14 @@ internal class PassageHosted { ) { val redirectUri = "$basePathOIDC/android/$packageName/logout" verifier = Utils.getRandomString() - val url = Uri - .parse("$basePathOIDC/logout") - .buildUpon() - .appendQueryParameter("id_token_hint", idToken) - .appendQueryParameter("client_id", appId) - .appendQueryParameter("state", verifier) - .appendQueryParameter("post_logout_redirect_uri", redirectUri) - .build() + val url = + Uri.parse("$basePathOIDC/logout") + .buildUpon() + .appendQueryParameter("id_token_hint", idToken) + .appendQueryParameter("client_id", appId) + .appendQueryParameter("post_logout_redirect_uri", redirectUri) + .appendQueryParameter("state", verifier) + .build() val customTabsIntent = CustomTabsIntent.Builder().build() customTabsIntent.launchUrl(activity, url) diff --git a/passage/src/main/java/id/passage/android/Utils.kt b/passage/src/main/java/id/passage/android/Utils.kt index cec411b..3150b8d 100644 --- a/passage/src/main/java/id/passage/android/Utils.kt +++ b/passage/src/main/java/id/passage/android/Utils.kt @@ -12,9 +12,11 @@ class Utils { val digits = '0'..'9' val upperCaseLetters = 'A'..'Z' val lowerCaseLetters = 'a'..'z' - val characters = (digits + + val characters = ( + digits + upperCaseLetters + - lowerCaseLetters) + lowerCaseLetters + ) .joinToString("") val random = SecureRandom() val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) @@ -31,6 +33,5 @@ class Utils { val digest = md.digest(bytes) return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) } - } } diff --git a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt index b355e8b..2273bed 100644 --- a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt +++ b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt @@ -40,8 +40,9 @@ public class FinishHostedServerException( message: String, ) : HostedAuthorizationError(message) - /** * Thrown when a error occurs During Hosted Logout. */ -public class HostedLogoutException(message: String) : HostedAuthorizationError(message) +public class HostedLogoutException( + message: String +) : HostedAuthorizationError(message) From 58bc04c32f0f123091b8d7f1802631631cddf0e5 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 12 Jul 2024 14:39:38 -0600 Subject: [PATCH 14/22] Wait time increased - testing --- .../java/id/passage/android/IntegrationTestConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt b/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt index 4afaf9a..4bea326 100644 --- a/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt +++ b/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt @@ -9,7 +9,7 @@ internal class IntegrationTestConfig { const val APP_ID_OTP = "Ezbk6fSdx9pNQ7v7UbVEnzeC" const val APP_ID_OIDC = "2ZWhX75KpwKKVdr4gxiZph9m" const val APP_ID_MAGIC_LINK = "Pea2GdtBHN3esylK4ZRlF19U" - const val WAIT_TIME_MILLISECONDS: Long = 8000 + const val WAIT_TIME_MILLISECONDS: Long = 15000 const val EXISTING_USER_EMAIL_OTP = "authentigator+1716916054778@ncor7c1m.mailosaur.net" const val EXISTING_USER_EMAIL_MAGIC_LINK = "authentigator+1716572384858@ncor7c1m.mailosaur.net" const val DEACTIVATED_USER_EMAIL_MAGIC_LINK = "authentigator+1716778790434@ncor7c1m.mailosaur.net" From d6110a89a883e540a96774da9092c033b5bb587d Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:05:02 -0600 Subject: [PATCH 15/22] Update integration-tests.yml to record sessions --- .github/workflows/integration-tests.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 21dff11..b70fe5d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,8 +1,10 @@ name: Integration tests + on: pull_request: push: branches: [main] + jobs: test: runs-on: ubuntu-latest @@ -31,7 +33,7 @@ jobs: - name: Gradle cache uses: gradle/actions/setup-gradle@v3 - + - name: AVD cache uses: actions/cache@v4 id: avd-cache @@ -52,7 +54,7 @@ jobs: arch: x86_64 script: echo "Generated AVD snapshot for caching." - - name: run tests + - name: run tests with screen recording uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} @@ -60,4 +62,15 @@ jobs: emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true arch: x86_64 - script: ./gradlew connectedDebugAndroidTest + script: | + adb shell screenrecord /sdcard/screenrecord.mp4 & + RECORD_PID=$! + ./gradlew connectedDebugAndroidTest + adb shell kill $RECORD_PID + adb pull /sdcard/screenrecord.mp4 + + - name: Upload screen recording + uses: actions/upload-artifact@v2 + with: + name: screenrecord + path: screenrecord.mp4 From 08d87eabcc30b2fbee2f96635e37f39e67517500 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:16:57 -0600 Subject: [PATCH 16/22] Update integration-tests.yml --- .github/workflows/integration-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index b70fe5d..baf5313 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -65,11 +65,12 @@ jobs: script: | adb shell screenrecord /sdcard/screenrecord.mp4 & RECORD_PID=$! - ./gradlew connectedDebugAndroidTest + ./gradlew connectedDebugAndroidTest || true adb shell kill $RECORD_PID adb pull /sdcard/screenrecord.mp4 - name: Upload screen recording + if: always() uses: actions/upload-artifact@v2 with: name: screenrecord From 9e310f7610e943019185a8b2ee494bce01860167 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:17:28 -0600 Subject: [PATCH 17/22] Rollback wait time --- .../java/id/passage/android/IntegrationTestConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt b/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt index 4bea326..4afaf9a 100644 --- a/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt +++ b/passage/src/androidTest/java/id/passage/android/IntegrationTestConfig.kt @@ -9,7 +9,7 @@ internal class IntegrationTestConfig { const val APP_ID_OTP = "Ezbk6fSdx9pNQ7v7UbVEnzeC" const val APP_ID_OIDC = "2ZWhX75KpwKKVdr4gxiZph9m" const val APP_ID_MAGIC_LINK = "Pea2GdtBHN3esylK4ZRlF19U" - const val WAIT_TIME_MILLISECONDS: Long = 15000 + const val WAIT_TIME_MILLISECONDS: Long = 8000 const val EXISTING_USER_EMAIL_OTP = "authentigator+1716916054778@ncor7c1m.mailosaur.net" const val EXISTING_USER_EMAIL_MAGIC_LINK = "authentigator+1716572384858@ncor7c1m.mailosaur.net" const val DEACTIVATED_USER_EMAIL_MAGIC_LINK = "authentigator+1716778790434@ncor7c1m.mailosaur.net" From c748504e567b8769a06d8ac3bba3492edd1a245b Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Mon, 15 Jul 2024 12:06:26 -0600 Subject: [PATCH 18/22] hostedLogout with idToken added --- .../src/main/java/id/passage/android/Passage.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 2697e12..6a46b91 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -720,11 +720,24 @@ public final class Passage( * @throws HostedLogoutException */ - public suspend fun hostedAuthLogout() { + public suspend fun hostedLogout() { val idToken = tokenStore.idToken ?: throw HostedLogoutException("Can't Logout - Missing Id Token") PassageHosted.logout(activity, idToken) tokenStore.clearAndRevokeTokens() } + /** + * Logout Method for Hosted Apps + * + * If your Passage app is Hosted, use this method to log out your user. This method will briefly open up a web view where it will log out the + * @param idToken The auth id token, used to log the user our of any remaining web sessions. + * @throws HostedLogoutException + */ + + public suspend fun hostedLogout(idToken: String) { + PassageHosted.logout(activity, idToken) + tokenStore.clearAndRevokeTokens() + } + // endregion } From 9b011664573047224e6e9db6564c57b915132128 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:50:42 -0600 Subject: [PATCH 19/22] Clean ups --- .../java/id/passage/android/HostedTests.kt | 97 ++++++------------- passage/src/debug/AndroidManifest.xml | 19 +--- .../java/id/passage/android/TestActivity.kt | 26 +---- passage/src/debug/res/values/strings.xml | 13 --- passage/src/debug/res/values/styles.xml | 5 - .../src/main/java/id/passage/android/Utils.kt | 14 +-- .../exceptions/HostedAuthorizationError.kt | 4 +- 7 files changed, 43 insertions(+), 135 deletions(-) delete mode 100644 passage/src/debug/res/values/strings.xml delete mode 100644 passage/src/debug/res/values/styles.xml diff --git a/passage/src/androidTest/java/id/passage/android/HostedTests.kt b/passage/src/androidTest/java/id/passage/android/HostedTests.kt index d761ed7..80224bb 100644 --- a/passage/src/androidTest/java/id/passage/android/HostedTests.kt +++ b/passage/src/androidTest/java/id/passage/android/HostedTests.kt @@ -1,22 +1,24 @@ package id.passage.android +import android.content.Intent +import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_DEFAULT import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasDataString +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiSelector import com.google.common.truth.Truth.assertThat import id.passage.android.IntegrationTestConfig.Companion.API_BASE_URL import id.passage.android.IntegrationTestConfig.Companion.APP_ID_OIDC -import id.passage.android.IntegrationTestConfig.Companion.EXISTING_USER_EMAIL_OTP import id.passage.android.exceptions.HostedAuthorizationError -import junit.framework.TestCase.assertNotNull -import junit.framework.TestCase.assertNull import junit.framework.TestCase.fail -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.containsString import org.junit.After import org.junit.Before import org.junit.Rule @@ -51,72 +53,31 @@ internal class HostedTests { ) @Test - fun testHostedAuthStart(): Unit = - runBlocking { + fun testAuthorizeWith() = + runTest { try { - hostedAuthLogin() - val user = passage.getCurrentUser() - assertNotNull(user) - } catch (e: Exception) { - fail("Test failed due to unexpected exception: ${e.message}") - } finally { - UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() - } - } + val expectedCodeChallengeMethod = "code_challenge_method=S256" + val expectedState = "state=" + val expectedCodeChallenge = "code_challenge=" - private suspend fun hostedAuthLogin() { - passage.hostedAuthStart() - delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - // Handle the Chrome welcome screen - val addAccountButton = device.findObject(UiSelector().className("android.widget.Button").text("Add account to device")) - val useWithoutAccountButton = device.findObject(UiSelector().className("android.widget.Button").text("Use without an account")) - if (addAccountButton.exists()) { - useWithoutAccountButton.click() - delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) - } - // Check if the user is on the login screen with the email field - val emailField = device.findObject(UiSelector().className("android.widget.EditText").instance(0)) - if (emailField.exists()) { - emailField.setText(EXISTING_USER_EMAIL_OTP) - val nextButton = device.findObject(UiSelector().className("android.widget.Button").text("Continue")) - nextButton.click() - } else { - // User is already logged in, click Continue button - val continueButton = device.findObject(UiSelector().className("android.widget.Button").text("Continue")) - if (continueButton.exists()) { - continueButton.click() - } - } - delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) - val otpCode = MailosaurAPIClient.getMostRecentOneTimePasscode() // Replace with the actual OTP code + passage.hostedAuthStart() - otpCode.forEachIndexed { index, char -> - val otpField = device.findObject(UiSelector().className("android.widget.EditText").instance(index)) - otpField.click() - otpField.setText(char.toString()) - Thread.sleep(200) - } - - delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) - val skipButton = device.findObject(UiSelector().className("android.widget.Button").text("Skip")) - if (skipButton.exists()) { - skipButton.click() - delay(IntegrationTestConfig.WAIT_TIME_MILLISECONDS) - } - } - - @Test - fun testHostedLogout(): Unit = - runBlocking { - try { - val alreadyAuthenticated = passage.getCurrentUser() - if (alreadyAuthenticated == null) hostedAuthLogin() - passage.hostedAuthLogout() - val user = passage.getCurrentUser() - assertNull(user) + intended( + allOf( + // Web browser is open + hasAction(Intent.ACTION_VIEW), + // Web browser is a Custom Chrome Tab + hasExtra("androidx.browser.customtabs.extra.SHARE_STATE", SHARE_STATE_DEFAULT), + hasDataString(containsString(expectedCodeChallengeMethod)), + hasDataString(containsString(expectedState)), + hasDataString(containsString(expectedCodeChallenge)), + ), + ) } catch (e: Exception) { fail("Test failed due to unexpected exception: ${e.message}") + } finally { + // Simulate a back press to dismiss the Custom Chrome Tab + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() } } @@ -131,4 +92,4 @@ internal class HostedTests { assertThat(e is HostedAuthorizationError) } } -} +} \ No newline at end of file diff --git a/passage/src/debug/AndroidManifest.xml b/passage/src/debug/AndroidManifest.xml index 4b60b56..db18a8d 100644 --- a/passage/src/debug/AndroidManifest.xml +++ b/passage/src/debug/AndroidManifest.xml @@ -4,21 +4,6 @@ - - - - - - - - - - - - - - + /> - + \ No newline at end of file diff --git a/passage/src/debug/java/id/passage/android/TestActivity.kt b/passage/src/debug/java/id/passage/android/TestActivity.kt index 6e47af6..c881b39 100644 --- a/passage/src/debug/java/id/passage/android/TestActivity.kt +++ b/passage/src/debug/java/id/passage/android/TestActivity.kt @@ -1,31 +1,11 @@ package id.passage.android -import android.content.Intent +import android.app.Activity import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import kotlinx.coroutines.runBlocking - -class TestActivity : AppCompatActivity() { - private lateinit var passage: Passage +internal class TestActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_test) - passage = Passage(this) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - val authCode = intent.data?.getQueryParameter("code") ?: "" - val state = intent.data?.getQueryParameter("state") ?: "" - if (authCode.isNotEmpty()) { - runBlocking { - try { - passage.hostedAuthFinish(authCode, "JkXmvBNPFTL0Zb7Ya7W4wc0o7cOi9o8K", state) - } catch (e: Exception) { - // Handle any exceptions - } - } - } } -} +} \ No newline at end of file diff --git a/passage/src/debug/res/values/strings.xml b/passage/src/debug/res/values/strings.xml deleted file mode 100644 index deeaa6e..0000000 --- a/passage/src/debug/res/values/strings.xml +++ /dev/null @@ -1,13 +0,0 @@ - - Test App - - - 2ZWhX75KpwKKVdr4gxiZph9m - fragile-greenyellow-bat.withpassage-uat.com - - [{ - \"include\": \"https://@string/passage_auth_origin/.well-known/assetlinks.json\" - }] - - - diff --git a/passage/src/debug/res/values/styles.xml b/passage/src/debug/res/values/styles.xml deleted file mode 100644 index 85a6ed8..0000000 --- a/passage/src/debug/res/values/styles.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/passage/src/main/java/id/passage/android/Utils.kt b/passage/src/main/java/id/passage/android/Utils.kt index 3150b8d..321ea6d 100644 --- a/passage/src/main/java/id/passage/android/Utils.kt +++ b/passage/src/main/java/id/passage/android/Utils.kt @@ -12,12 +12,12 @@ class Utils { val digits = '0'..'9' val upperCaseLetters = 'A'..'Z' val lowerCaseLetters = 'a'..'z' - val characters = ( - digits + - upperCaseLetters + - lowerCaseLetters - ) - .joinToString("") + val characters = + ( + digits + + upperCaseLetters + + lowerCaseLetters + ).joinToString("") val random = SecureRandom() val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) for (i in 0 until SECRET_STRING_LENGTH) { @@ -34,4 +34,4 @@ class Utils { return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) } } -} +} \ No newline at end of file diff --git a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt index 2273bed..c2bdb7a 100644 --- a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt +++ b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt @@ -44,5 +44,5 @@ public class FinishHostedServerException( * Thrown when a error occurs During Hosted Logout. */ public class HostedLogoutException( - message: String -) : HostedAuthorizationError(message) + message: String, +) : HostedAuthorizationError(message) \ No newline at end of file From 4e40a89af67d061cf43e398eaeaf8b542d3ded8d Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:56:04 -0600 Subject: [PATCH 20/22] clean ups --- .github/workflows/integration-tests.yml | 18 ++---------------- .../java/id/passage/android/HostedTests.kt | 2 +- .../java/id/passage/android/TestActivity.kt | 2 +- .../main/java/id/passage/android/Passage.kt | 4 ++-- .../java/id/passage/android/PassageHosted.kt | 15 ++++++++------- .../exceptions/HostedAuthorizationError.kt | 2 +- 6 files changed, 15 insertions(+), 28 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index baf5313..acd95a0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,10 +1,8 @@ name: Integration tests - on: pull_request: push: branches: [main] - jobs: test: runs-on: ubuntu-latest @@ -54,7 +52,7 @@ jobs: arch: x86_64 script: echo "Generated AVD snapshot for caching." - - name: run tests with screen recording + - name: run tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} @@ -62,16 +60,4 @@ jobs: emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true arch: x86_64 - script: | - adb shell screenrecord /sdcard/screenrecord.mp4 & - RECORD_PID=$! - ./gradlew connectedDebugAndroidTest || true - adb shell kill $RECORD_PID - adb pull /sdcard/screenrecord.mp4 - - - name: Upload screen recording - if: always() - uses: actions/upload-artifact@v2 - with: - name: screenrecord - path: screenrecord.mp4 + script: ./gradlew connectedDebugAndroidTest \ No newline at end of file diff --git a/passage/src/androidTest/java/id/passage/android/HostedTests.kt b/passage/src/androidTest/java/id/passage/android/HostedTests.kt index 80224bb..a716048 100644 --- a/passage/src/androidTest/java/id/passage/android/HostedTests.kt +++ b/passage/src/androidTest/java/id/passage/android/HostedTests.kt @@ -92,4 +92,4 @@ internal class HostedTests { assertThat(e is HostedAuthorizationError) } } -} \ No newline at end of file +} diff --git a/passage/src/debug/java/id/passage/android/TestActivity.kt b/passage/src/debug/java/id/passage/android/TestActivity.kt index c881b39..f3e23a4 100644 --- a/passage/src/debug/java/id/passage/android/TestActivity.kt +++ b/passage/src/debug/java/id/passage/android/TestActivity.kt @@ -8,4 +8,4 @@ internal class TestActivity : Activity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_test) } -} \ No newline at end of file +} diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 6a46b91..12bd6bc 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -696,11 +696,11 @@ public final class Passage( * @throws HostedAuthorizationError */ - suspend fun hostedAuthFinish( + public suspend fun hostedAuthFinish( code: String, clientSecret: String, state: String, - ): Pair { + ): Pair { try { val finishHostedAuthResult = PassageHosted.finishHostedAuth(code, clientSecret, state) finishHostedAuthResult.let { (authResult, idToken) -> diff --git a/passage/src/main/java/id/passage/android/PassageHosted.kt b/passage/src/main/java/id/passage/android/PassageHosted.kt index 1865158..6e50514 100644 --- a/passage/src/main/java/id/passage/android/PassageHosted.kt +++ b/passage/src/main/java/id/passage/android/PassageHosted.kt @@ -124,13 +124,14 @@ internal class PassageHosted { val redirectUri = "$basePathOIDC/android/$packageName/logout" verifier = Utils.getRandomString() val url = - Uri.parse("$basePathOIDC/logout") - .buildUpon() - .appendQueryParameter("id_token_hint", idToken) - .appendQueryParameter("client_id", appId) - .appendQueryParameter("post_logout_redirect_uri", redirectUri) - .appendQueryParameter("state", verifier) - .build() + Uri + .parse("$basePathOIDC/logout") + .buildUpon() + .appendQueryParameter("id_token_hint", idToken) + .appendQueryParameter("client_id", appId) + .appendQueryParameter("post_logout_redirect_uri", redirectUri) + .appendQueryParameter("state", verifier) + .build() val customTabsIntent = CustomTabsIntent.Builder().build() customTabsIntent.launchUrl(activity, url) diff --git a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt index c2bdb7a..fe96382 100644 --- a/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt +++ b/passage/src/main/java/id/passage/android/exceptions/HostedAuthorizationError.kt @@ -45,4 +45,4 @@ public class FinishHostedServerException( */ public class HostedLogoutException( message: String, -) : HostedAuthorizationError(message) \ No newline at end of file +) : HostedAuthorizationError(message) From d212f54db6fa15148f925538afa880a3fe9a13ec Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:57:50 -0600 Subject: [PATCH 21/22] Ktlint fixes --- passage/src/main/java/id/passage/android/Passage.kt | 1 - passage/src/main/java/id/passage/android/Utils.kt | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index 12bd6bc..f12a2b0 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -15,7 +15,6 @@ import id.passage.android.api.UsersAPI import id.passage.android.exceptions.* import id.passage.android.model.ActivateMagicLinkRequest import id.passage.android.model.ActivateOneTimePasscodeRequest -import id.passage.android.model.AuthResult import id.passage.android.model.AuthenticatorAttachment import id.passage.android.model.GetMagicLinkStatusRequest import id.passage.android.model.LoginMagicLinkRequest diff --git a/passage/src/main/java/id/passage/android/Utils.kt b/passage/src/main/java/id/passage/android/Utils.kt index 321ea6d..53b5362 100644 --- a/passage/src/main/java/id/passage/android/Utils.kt +++ b/passage/src/main/java/id/passage/android/Utils.kt @@ -14,10 +14,10 @@ class Utils { val lowerCaseLetters = 'a'..'z' val characters = ( - digits + - upperCaseLetters + - lowerCaseLetters - ).joinToString("") + digits + + upperCaseLetters + + lowerCaseLetters + ).joinToString("") val random = SecureRandom() val stringBuilder = StringBuilder(SECRET_STRING_LENGTH) for (i in 0 until SECRET_STRING_LENGTH) { @@ -34,4 +34,4 @@ class Utils { return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) } } -} \ No newline at end of file +} From ea85ffc547e087951c652dcaf36c92577fcc0a67 Mon Sep 17 00:00:00 2001 From: Sina <65910646+SinaSeylani@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:06:11 -0600 Subject: [PATCH 22/22] More cleanups --- passage/src/main/java/id/passage/android/Passage.kt | 2 -- .../main/java/id/passage/android/PassageHosted.kt | 3 ++- passage/src/main/res/values/strings.xml | 12 ++++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 passage/src/main/res/values/strings.xml diff --git a/passage/src/main/java/id/passage/android/Passage.kt b/passage/src/main/java/id/passage/android/Passage.kt index f12a2b0..805bf5a 100644 --- a/passage/src/main/java/id/passage/android/Passage.kt +++ b/passage/src/main/java/id/passage/android/Passage.kt @@ -38,7 +38,6 @@ public final class Passage( internal companion object { internal const val TAG = "Passage" internal var BASE_PATH = "https://auth.passage.id/v1" - internal lateinit var packageName: String internal lateinit var appId: String internal lateinit var authOrigin: String internal var language: String? = null @@ -81,7 +80,6 @@ public final class Passage( authOrigin = getRequiredResourceFromApp(activity, "passage_auth_origin") Companion.appId = appId ?: getRequiredResourceFromApp(activity, "passage_app_id") language = getOptionalResourceFromApp(activity, "passage_language") - packageName = activity.packageName val usePassageStore = getOptionalResourceFromApp(activity, "use_passage_store") if (usePassageStore != "false") { diff --git a/passage/src/main/java/id/passage/android/PassageHosted.kt b/passage/src/main/java/id/passage/android/PassageHosted.kt index 6e50514..0178ce5 100644 --- a/passage/src/main/java/id/passage/android/PassageHosted.kt +++ b/passage/src/main/java/id/passage/android/PassageHosted.kt @@ -25,9 +25,10 @@ internal class PassageHosted { private const val CODE_CHALLENGE_METHOD = "S256" private val basePathOIDC = "https://${Passage.authOrigin}" private val appId = Passage.appId - private val packageName = Passage.packageName + private var packageName = "" internal fun openChromeTab(activity: Activity) { + packageName = activity.packageName val redirectUri = "$basePathOIDC/android/$packageName/callback" state = Utils.getRandomString() val randomString = Utils.getRandomString() diff --git a/passage/src/main/res/values/strings.xml b/passage/src/main/res/values/strings.xml new file mode 100644 index 0000000..6541f97 --- /dev/null +++ b/passage/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + Test App + + + try-uat.passage.dev + + [{ + \"include\": \"https://@string/passage_auth_origin/.well-known/assetlinks.json\" + }] + + + \ No newline at end of file