Skip to content

Commit

Permalink
Merge pull request #56 from passageidentity/PSG-4172
Browse files Browse the repository at this point in the history
PSG-4172,  PSG-4173, PSG-4174 and PSG-4206
  • Loading branch information
SinaSeylani authored Jul 15, 2024
2 parents 5e7c4e0 + ea85ffc commit bdf1993
Show file tree
Hide file tree
Showing 11 changed files with 439 additions and 36 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- name: Gradle cache
uses: gradle/actions/setup-gradle@v3

- name: AVD cache
uses: actions/cache@v4
id: avd-cache
Expand Down Expand Up @@ -60,4 +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: ./gradlew connectedDebugAndroidTest
script: ./gradlew connectedDebugAndroidTest
95 changes: 95 additions & 0 deletions passage/src/androidTest/java/id/passage/android/HostedTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 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.exceptions.HostedAuthorizationError
import junit.framework.TestCase.fail
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
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
internal class HostedTests {
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<TestActivity?>? =
ActivityScenarioRule(
TestActivity::class.java,
)

@Test
fun testAuthorizeWith() =
runTest {
try {
val expectedCodeChallengeMethod = "code_challenge_method=S256"
val expectedState = "state="
val expectedCodeChallenge = "code_challenge="

passage.hostedAuthStart()

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()
}
}

@Test
fun testFinishAuthorizationInvalidRequest() =
runTest {
try {
val invalidAuthCode = "INVALID_AUTH_CODE"
passage.hostedAuthFinish(invalidAuthCode, "", "")
fail("Test should throw FinishOIDCAuthenticationInvalidRequestException")
} catch (e: Exception) {
assertThat(e is HostedAuthorizationError)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]"
Expand Down
2 changes: 1 addition & 1 deletion passage/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
android:exported="true"
/>
</application>
</manifest>
</manifest>
85 changes: 84 additions & 1 deletion passage/src/main/java/id/passage/android/Passage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 appId: String
internal lateinit var authOrigin: String
internal var language: String? = null
Expand Down Expand Up @@ -653,5 +652,89 @@ 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

/**
* Authentication Method for Hosted Apps
*
* 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() {
PassageHosted.openChromeTab(
activity,
)
}

/**
* Finish Hosted Auth for Hosted Apps
*
* 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
*/

public suspend fun hostedAuthFinish(
code: String,
clientSecret: String,
state: String,
): Pair<PassageAuthResult, String> {
try {
val finishHostedAuthResult = PassageHosted.finishHostedAuth(code, clientSecret, state)
finishHostedAuthResult.let { (authResult, idToken) ->
handleAuthResult(authResult)
handleIdToken(idToken)
}
return finishHostedAuthResult
} catch (e: Exception) {
throw HostedAuthorizationError.convert(e)
}
}

/**
* 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
* @throws HostedLogoutException
*/

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
}
151 changes: 151 additions & 0 deletions passage/src/main/java/id/passage/android/PassageHosted.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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.exceptions.HostedAuthorizationError
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

internal class PassageHosted {
internal companion object {
private var verifier = ""
private var state = ""
private const val CODE_CHALLENGE_METHOD = "S256"
private val basePathOIDC = "https://${Passage.authOrigin}"
private val appId = Passage.appId
private var packageName = ""

internal fun openChromeTab(activity: Activity) {
packageName = activity.packageName
val redirectUri = "$basePathOIDC/android/$packageName/callback"
state = Utils.getRandomString()
val randomString = Utils.getRandomString()
verifier = Utils.getRandomString()
val codeChallenge = Utils.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 = "$basePathOIDC/authorize?$newParams"
val intent = CustomTabsIntent.Builder().build()
intent.launchUrl(activity, Uri.parse(url))
}

internal suspend fun finishHostedAuth(
code: String,
clientSecret: String,
state: String,
): Pair<AuthResult, String> {
val redirectUri = "$basePathOIDC/android/$packageName/callback"
if (PassageHosted.state != state) {
throw HostedAuthorizationError("State is Invalid")
}
var authResult: AuthResult
var idToken: String
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 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()

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.accessToken,
redirectUrl = "",
refreshToken = apiResponse.refreshToken,
refreshTokenExpiration = null,
)
idToken = apiResponse.idToken
} else {
throw Exception("Response body is null : ${response.code} ${response.message}")
}
}
}
return Pair(authResult, idToken)
}

fun logout(
activity: Activity,
idToken: String,
) {
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()

val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(activity, url)
}
}
}

@JsonClass(generateAdapter = true)
data class OIDCResponse(
@Json(name = "access_token")
val accessToken: String,
@Json(name = "refresh_token")
val refreshToken: String?,
@Json(name = "id_token")
val idToken: String,
)
Loading

0 comments on commit bdf1993

Please sign in to comment.