-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #56 from passageidentity/PSG-4172
PSG-4172, PSG-4173, PSG-4174 and PSG-4206
- Loading branch information
Showing
11 changed files
with
439 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
passage/src/androidTest/java/id/passage/android/HostedTests.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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]" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,4 +6,4 @@ | |
android:exported="true" | ||
/> | ||
</application> | ||
</manifest> | ||
</manifest> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
passage/src/main/java/id/passage/android/PassageHosted.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
Oops, something went wrong.