Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PSG-4172, PSG-4173, PSG-4174 and PSG-4206 #56

Merged
merged 22 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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