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

PACUtils to 1.7.x #115

Merged
merged 10 commits into from
Nov 29, 2023
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ buildscript {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Constants.BuildScript.kotlinVersion}")
// releasing
classpath("io.github.gradle-nexus:publish-plugin:1.1.0")
// tests
classpath("com.github.bjoernq:unmockplugin:0.7.9")
}
}

Expand Down
26 changes: 25 additions & 1 deletion docs/Using-Operations-Service.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
- [Operations API Reference](#operations-api-reference)
- [UserOperation](#useroperation)
- [Creating a Custom Operation](#creating-a-custom-operation)
- [TOTP ProximityCheck](#totp-proximitycheck)
- [ProximityCheck](#proximitycheck)

## Introduction
<!-- end -->

Expand Down Expand Up @@ -559,3 +560,26 @@ When the app is launched via a deeplink, preserve the data from the deeplink and

- Authorizing the ProximityCheck
When authorizing, the SDK will by default add `timestampSigned` to the `ProximityCheck` object. This timestamp indicates when the operation was signed.

### PACUtils
- For convenience, utility class for parsing and extracting data from QR codes and deeplinks used in the PAC (Proximity Anti-fraud Check), is provided.

```kotlin
/** Data payload which is returned from the parser */
data class PACData(

/** The ID of the operation associated with the TOTP */
val operationId: String,

/** The actual Time-based one time password */
val totp: String?
)
```

- two methods are provided:
- `parseDeeplink(uri: Uri): PACData?` - uri is expected to be in format `"scheme://code=$JWT"` or `scheme://operation?oid=5b753d0d-d59a-49b7-bec4-eae258566dbb&potp=12345678}`
- `parseQRCode(code: String): PACData?` - code is to be expected in the same format as deeplink formats or as a plain JWT
- mentioned JWT should be in format `{“typ”:”JWT”, “alg”:”none”}.{“oid”:”5b753d0d-d59a-49b7-bec4-eae258566dbb”, “potp”:”12345678”} `

- Accepted formats:
- notice that totp key in JWT and in query shall be `potp`!
1 change: 1 addition & 0 deletions library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
plugins {
id("com.android.library")
kotlin("android")
id("de.mobilej.unmock")
}

android {
Expand Down
2 changes: 1 addition & 1 deletion library/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
# and limitations under the License.
#

VERSION_NAME=1.7.1
VERSION_NAME=1.7.2-SNAPSHOT
GROUP_ID=com.wultra.android.mtokensdk
ARTIFACT_ID=wultra-mtoken-sdk
Original file line number Diff line number Diff line change
Expand Up @@ -23,48 +23,55 @@ import com.google.gson.annotations.SerializedName
import com.wultra.android.mtokensdk.common.Logger

/**
* Utility class used for handling TOTP
* Utility class used for handling Proximity Anti-fraud Checks
*/
class TOTPUtils {
class PACUtils {

/** Data payload which is returned from JWT parser */
data class OperationTOTPData(
/** Data payload which is returned from the parser */
data class PACData(

/** The ID of the operations associated with the TOTP */
@SerializedName("potp")
val totp: String,
/** The ID of the operation associated with the TOTP */
@SerializedName("oid")
val operationId: String,

/** The actual Time-based one time password */
@SerializedName("oid")
val operationId: String
@SerializedName(value = "potp", alternate = ["totp"])
val totp: String?
)

companion object {

/** Method accepts deeplink Uri and returns payload data or null */
fun parseDeeplink(uri: Uri?): OperationTOTPData? {
val query = uri?.query ?: return null
val queryItems = query.split("&").associate {
val (key, value) = it.split("=")
key to value
}
fun parseDeeplink(uri: Uri): PACData? {

queryItems["code"]?.let {
return parseJWT(it)
// Deeplink can have two query items with operationId & optional totp or single query item with JWT value
uri.getQueryParameter("oid")?.let { operationId ->
if (uri.query?.contains(operationId) == false) {
Logger.e("Operation could not be resolved - probably contains invalid characters - please, encode the URL first")
return null
}
val totp = uri.getQueryParameter("totp") ?: uri.getQueryParameter("potp")
return PACData(operationId, totp)
} ?: uri.queryParameterNames.firstOrNull()?.let {
return parseJWT(uri.getQueryParameter(it) ?: "")
} ?: run {
Logger.e("Failed to parse deeplink. Key `code` not found")
Logger.e("Failed to parse deeplink. Valid keys not found in Uri: $uri")
return null
}

Logger.e("Failed to parse deeplink from $uri")
return null
}

/** Method accepts scanned code as a String and returns payload data or null */
fun parseQRCode(code: String): OperationTOTPData? {
return parseJWT(code)
/** Method accepts scanned code as a String and returns PAC data */
fun parseQRCode(code: String): PACData? {
val uri = Uri.parse(code)
// if the QR code is in the deeplink format parse it the same way as the deeplink
return if (uri.scheme != null) {
parseDeeplink(uri)
} else {
parseJWT(code)
}
}

private fun parseJWT(code: String): OperationTOTPData? {
private fun parseJWT(code: String): PACData? {
val jwtParts = code.split(".")
if (jwtParts.size > 1) {
// At this moment we don't care about header, we want only payload which is the second part of JWT
Expand All @@ -74,7 +81,7 @@ class TOTPUtils {
return try {
val dataPayload = Base64.decode(base64EncodedData, Base64.DEFAULT)
val json = String(dataPayload, Charsets.UTF_8)
Gson().fromJson(json, OperationTOTPData::class.java)
Gson().fromJson(json, PACData::class.java)
} catch (e: Exception) {
Logger.e("Failed to decode QR JWT from: $code")
Logger.e("With error: ${e.message}")
Expand Down
154 changes: 154 additions & 0 deletions library/src/test/java/PACUtilsTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2022 Wultra s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions
* and limitations under the License.
*/

package com.wultra.android.mtokensdk.api.operation

import android.net.Uri
import com.wultra.android.mtokensdk.operation.PACUtils
import org.junit.Assert
import org.junit.Test

class PACUtilsTests {

@Test
fun `test parseQRCode with empty code`() {
val code = ""
Assert.assertNull(PACUtils.parseQRCode(code))
}

@Test
fun testQRPACParserWithShortInvalidCode() {
val code = "abc"
Assert.assertNull(PACUtils.parseQRCode(code))
}

@Test
fun testQRTPACParserWithValidDeeplinkCode() {
val code = "scheme://operation?oid=6a1cb007-ff75-4f40-a21b-0b546f0f6cad&potp=73743194"
val parsed = PACUtils.parseQRCode(code)
Assert.assertEquals("Parsing of totp", "73743194", parsed?.totp)
Assert.assertEquals("Parsing of operationId", "6a1cb007-ff75-4f40-a21b-0b546f0f6cad", parsed?.operationId)
}

@Test
fun testQRTPACParserWithInvalidDeeplinkCodeAndBase64OID() {
val code = "scheme://operation?oid=E/+DRFVmd4iZABEiM0RVZneImQARIjNEVWZ3iJkAESIzRFVmd4iZAA=&totp=12345678"
val parsed = PACUtils.parseQRCode(code)
Assert.assertNull(parsed?.totp)
Assert.assertNull(parsed?.operationId)
}

@Test
fun testQRTPACParserWithValidDeeplinkCodeAndBase64EncodedOID() {
val code = "scheme://operation?oid=E%2F%2BDRFVmd4iZABEiM0RVZneImQARIjNEVWZ3iJkAESIzRFVmd4iZAA%3D&totp=12345678"
val parsed = PACUtils.parseQRCode(code)
Assert.assertEquals("Parsing of totp", "12345678", parsed?.totp)
Assert.assertEquals("Parsing of operationId", "E/+DRFVmd4iZABEiM0RVZneImQARIjNEVWZ3iJkAESIzRFVmd4iZAA=", parsed?.operationId)
}

fun testQRPACParserWithValidJWT() {
val code = "eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiIzYjllZGZkMi00ZDgyLTQ3N2MtYjRiMy0yMGZhNWM5OWM5OTMiLCJwb3RwIjoiMTQzNTc0NTgifQ=="
val parsed = PACUtils.parseQRCode(code)
Assert.assertEquals("Parsing of totp", "14357458", parsed?.totp)
Assert.assertEquals("Parsing of operationId", "3b9edfd2-4d82-477c-b4b3-20fa5c99c993", parsed?.operationId)
}

@Test
fun testQRPACParserWithValidJWTWithoutPadding() {
val code = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJvaWQiOiJMRG5JY0NjRGhjRHdHNVNLejhLeWdQeG9PbXh3dHpJc29zMEUrSFBYUHlvIiwicG90cCI6IjU4NTkwMDU5In0"
val parsed = PACUtils.parseQRCode(code)
Assert.assertEquals("Parsing of totp", "58590059", parsed?.totp)
Assert.assertEquals("Parsing of operationId", "LDnIcCcDhcDwG5SKz8KygPxoOmxwtzIsos0E+HPXPyo", parsed?.operationId)
}

@Test
fun testQRPACParserWithInvalidJWT() {
val code = "eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9eyJvaWQiOiIzYjllZGZkMi00ZDgyLTQ3N2MtYjRiMy0yMGZhNWM5OWM5OTMiLCJwb3RwIjoiMTQzNTc0NTgifQ=="
val parsed = PACUtils.parseQRCode(code)
Assert.assertNull("Parsing of should fail", parsed)
}

@Test
fun testQRPACParserWithInvalidJWT2() {
val code = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.1eyJvaWQiOiJMRG5JY0NjRGhjRHdHNVNLejhLeWdQeG9PbXh3dHpJc29zMEUrSFBYUHlvIiwicG90cCI6IjU4NTkwMDU5In0"
val parsed = PACUtils.parseQRCode(code)
Assert.assertNull("Parsing of should fail", parsed)
}

@Test
fun testQRPACParserWithInvalidJWT3() {
val code = ""
val parsed = PACUtils.parseQRCode(code)
Assert.assertNull("Parsing of should fail", parsed)
}

@Test
fun testQRPACParserWithInvalidJWT4() {
val code = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.1eyJvaWQiOiJMRG5JY0NjR.GhjRHdHNVNLejhLeWdQeG9PbXh3dHpJc29zMEUrSFBYUHlvIiwicG90cCI6IjU4NTkwMDU5In0====="
val parsed = PACUtils.parseQRCode(code)
Assert.assertNull("Parsing of should fail", parsed)
}

@Test
fun testDeeplinkParserWithInvalidPACCode() {
val code = "operation?oid=df6128fc-ca51-44b7-befa-ca0e1408aa63&potp=56725494"
Assert.assertNull(PACUtils.parseQRCode(code))
}

@Test
fun testDeeplinkPACParserWithInvalidURL() {
val url = Uri.parse("scheme://an-invalid-url.com")
Assert.assertNull(PACUtils.parseDeeplink(url))
}

@Test
fun testDeeplinkParserWithValidURLButInvalidQuery() {
val url = Uri.parse("scheme://operation?code=abc")
Assert.assertNull(PACUtils.parseDeeplink(url))
}

@Test
fun testDeeplinkPACParserWithValidJWTCode() {
val url = Uri.parse("scheme://operation?code=eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiIzYjllZGZkMi00ZDgyLTQ3N2MtYjRiMy0yMGZhNWM5OWM5OTMiLCJwb3RwIjoiMTQzNTc0NTgifQ==")
val parsed = PACUtils.parseDeeplink(url)
Assert.assertEquals("Parsing of totp failed", "14357458", parsed?.totp)
Assert.assertEquals("Parsing of operationId failed", "3b9edfd2-4d82-477c-b4b3-20fa5c99c993", parsed?.operationId,)
}

@Test
fun testDeeplinkParserWithValidPACCode() {
val url = Uri.parse("scheme://operation?oid=df6128fc-ca51-44b7-befa-ca0e1408aa63&potp=56725494")
val parsed = PACUtils.parseDeeplink(url)
Assert.assertEquals("Parsing of totp failed", "56725494", parsed?.totp)
Assert.assertEquals("Parsing of operationId failed", "df6128fc-ca51-44b7-befa-ca0e1408aa63", parsed?.operationId)
}

@Test
fun testDeeplinkPACParserWithValidAnonymousDeeplinkQRCode() {
val code = "scheme://operation?oid=df6128fc-ca51-44b7-befa-ca0e1408aa63"
val parsed = PACUtils.parseQRCode(code)
Assert.assertNull(parsed?.totp)
Assert.assertEquals("Parsing of operationId failed", "df6128fc-ca51-44b7-befa-ca0e1408aa63", parsed?.operationId)
}

@Test
fun testDeeplinkPACParserWithAnonymousJWTQRCodeWithOnlyOperationId() {
val code = "eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiI1YWM0YjNlOC05MjZmLTQ1ZjAtYWUyOC1kMWJjN2U2YjA0OTYifQ=="
val parsed = PACUtils.parseQRCode(code)
Assert.assertNull(parsed?.totp)
Assert.assertEquals("Parsing of operationId failed", "5ac4b3e8-926f-45f0-ae28-d1bc7e6b0496", parsed?.operationId)
}
}
Loading