Skip to content

Commit

Permalink
PACUtils (#117)
Browse files Browse the repository at this point in the history
  • Loading branch information
kober32 authored Nov 30, 2023
1 parent a03ab7d commit 33527a8
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 28 deletions.
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.8.0-SNAPSHOT
VERSION_NAME=1.8.1-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)
}
}

0 comments on commit 33527a8

Please sign in to comment.