Skip to content

Commit

Permalink
1.7.x PAC with claim (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
Hopsaheysa authored Aug 1, 2024
1 parent 6903148 commit d28abb5
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 3 deletions.
18 changes: 15 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,27 @@ on:
jobs:
tests:
name: Tests
runs-on: macos-latest
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Setup Java 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'

- name: Test the app (unit test)
run: ./gradlew clean test

- name: Prepare configuration for integration tests
env:
CL_URL: ${{ secrets.TESTS_CL_URL }}
Expand All @@ -35,8 +44,11 @@ jobs:
OP_URL: ${{ secrets.TESTS_OP_URL }}
IN_URL: ${{ secrets.TESTS_IN_URL }}
run: echo -e tests.sdk.cloudServerUrl="$CL_URL"\\ntests.sdk.cloudServerLogin="$CL_LGN"\\ntests.sdk.cloudServerPassword="$CL_PWD"\\ntests.sdk.cloudApplicationId="$CL_AID"\\ntests.sdk.enrollmentServerUrl="$ER_URL"\\ntests.sdk.operationsServerUrl="$OP_URL"\\ntests.sdk.inboxServerUrl="$IN_URL"\\ntests.sdk.appKey="$APP_KEY"\\ntests.sdk.appSecret="$APP_SECRET"\\ntests.sdk.masterServerPublicKey="$MASTER_SERVER_PUBLIC_KEY" > configs/integration-tests.properties

- name: Test the app (integration tests)
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
script: ./gradlew clean connectedAndroidTest --info
arch: x86_64
target: default
script: ./gradlew connectedAndroidTest --info
41 changes: 41 additions & 0 deletions docs/Using-Operations-Service.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- [Start Periodic Polling](#start-periodic-polling)
- [Approve an Operation](#approve-an-operation)
- [Reject an Operation](#reject-an-operation)
- [Operation detail](#operation-detail)
- [Claim the Operation](#claim-the-operation)
- [Off-line Authorization](#off-line-authorization)
- [Operations API Reference](#operations-api-reference)
- [UserOperation](#useroperation)
Expand Down Expand Up @@ -195,6 +197,45 @@ fun reject(operation: IOperation, reason: RejectionReason) {
}
```

## Operation detail

To get a detail of the operation based on operation ID use `IOperationsService.getDetail`. Operation detail is confirmed by the possession factor so there is no need for creating `PowerAuthAuthentication` object. The returned result is the operation and its current status.

```kotlin
// Retrieve operation details based on the operation ID.
fun getDetail(operationId: String) {
this.operationService.getDetail(operationId: operationId) {
it.onSuccess {
// process operation
}.onFailure {
// show error UI
}
}
}
```

## Claim the Operation

To claim a non-persolized operation use `IOperationsService.claim`.

A non-personalized operation refers to an operation that is initiated without a specific operationId. In this state, the operation is not tied to a particular user and lacks a unique identifier.

Operation claim is confirmed by the possession factor so there is no need for creating `PowerAuthAuthentication` object. Returned result is the operation and its current status. You can simply use it with the following example.

```kotlin
// Assigns the 'non-personalized' operation to the user
fun claim(operationId: String) {
this.operationService.claim(operationId: operationId) {
it.onSuccess {
// process operation
}.onFailure {
// show error UI
}
}
}
```


## Operation History

You can retrieve an operation history via the `IOperationsService.getHistory` method. The returned result is operations and their current status.
Expand Down
55 changes: 55 additions & 0 deletions library/src/androidTest/java/IntegrationTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ package com.wultra.android.mtokensdk.test

import com.wultra.android.mtokensdk.api.operation.model.OperationHistoryEntry
import com.wultra.android.mtokensdk.api.operation.model.OperationHistoryEntryStatus
import com.wultra.android.mtokensdk.api.operation.model.PreApprovalScreen
import com.wultra.android.mtokensdk.api.operation.model.ProximityCheck
import com.wultra.android.mtokensdk.api.operation.model.ProximityCheckType
import com.wultra.android.mtokensdk.api.operation.model.QROperationParser
import com.wultra.android.mtokensdk.api.operation.model.UserOperation
import com.wultra.android.mtokensdk.operation.*
Expand Down Expand Up @@ -274,4 +277,56 @@ class IntegrationTests {

Assert.assertTrue(verifiedResult.otpValid)
}

@Test
fun testDetail() {
val op = IntegrationUtils.createNonPersonalizedPACOperation(IntegrationUtils.Companion.Factors.F_2FA)
val future = CompletableFuture<UserOperation>()

ops.getDetail(op.operationId) { result ->
result.onSuccess { future.complete(it) }
.onFailure { future.completeExceptionally(it) }
}

val operation = future.get(20, TimeUnit.SECONDS)
Assert.assertTrue("Failed to create & get the operation", operation != null)
Assert.assertEquals("Operations ids are not equal", op.operationId, operation.id)
}

@Test
fun testClaim() {
val op = IntegrationUtils.createNonPersonalizedPACOperation(IntegrationUtils.Companion.Factors.F_2FA)
val future = CompletableFuture<UserOperation>()

ops.claim(op.operationId) { result ->
result.onSuccess { future.complete(it) }
.onFailure { future.completeExceptionally(it) }
}

val operation = future.get(20, TimeUnit.SECONDS)

Assert.assertEquals("Incorrect type of preapproval screen", operation.ui?.preApprovalScreen?.type, PreApprovalScreen.Type.QR_SCAN)

val totp = IntegrationUtils.getOperation(op).proximityOtp
Assert.assertNotNull("Even with proximityCheckEnabled: true, in proximityOtp nil", totp)

operation.proximityCheck = ProximityCheck(totp!!, ProximityCheckType.QR_CODE)

val authorizedFuture = CompletableFuture<UserOperation?>()
var auth = PowerAuthAuthentication.possessionWithPassword("xxxx") // wrong password on purpose

ops.authorizeOperation(operation, auth) { result ->
result.onSuccess { authorizedFuture.completeExceptionally(Exception("Operation should not be authorized")) }
.onFailure { authorizedFuture.complete(null) }
}
Assert.assertNull(authorizedFuture.get(20, TimeUnit.SECONDS))

auth = PowerAuthAuthentication.possessionWithPassword(pin)
val authorizedFuture2 = CompletableFuture<Any?>()
ops.authorizeOperation(operation, auth) { result ->
result.onSuccess { authorizedFuture2.complete(null) }
.onFailure { authorizedFuture2.completeExceptionally(it) }
}
Assert.assertNull(authorizedFuture2.get(20, TimeUnit.SECONDS))
}
}
37 changes: 37 additions & 0 deletions library/src/androidTest/java/IntegrationUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,32 @@ class IntegrationUtils {
return makeCall(opBody, "$cloudServerUrl/v2/operations")
}

@Throws
fun createNonPersonalizedPACOperation(factors: Factors): NonPersonalisedTOTPOperationObject {
val opBody = when (factors) {
Factors.F_2FA -> { """
{
"template": "login_preApproval",
"proximityCheckEnabled": true,
"parameters": {
"party.id": "666",
"party.name": "Datová schránka",
"session.id": "123",
"session.ip-address": "192.168.0.1"
}
}
""".trimIndent()
}
}
// create an operation on the nextstep server
return makeCall(opBody, "$cloudServerUrl/v2/operations")
}

@Throws
fun getOperation(operation: NonPersonalisedTOTPOperationObject): NonPersonalisedTOTPOperationObject {
return makeCall(null, "$cloudServerUrl/v2/operations/${operation.operationId}", "GET")
}

@Throws
fun getQROperation(operation: OperationObject): QRData {
return makeCall(null, "$cloudServerUrl/v2/operations/${operation.operationId}/offline/qr?registrationId=$registrationId", "GET")
Expand Down Expand Up @@ -275,6 +301,17 @@ data class OperationObject(
val timestampExpires: Double
)

data class NonPersonalisedTOTPOperationObject(
val operationId: String,
val status: String,
val operationType: String,
val failureCount: Int,
val maxFailureCount: Int,
val timestampCreated: Double,
val timestampExpires: Double,
val proximityOtp: String?
)

data class QRData(
val operationQrCodeData: String,
val nonce: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ internal class OperationListResponse(
internal class OperationHistoryResponse(responseObject: List<OperationHistoryEntry>, status: Status): ObjectResponse<List<OperationHistoryEntry>>(responseObject, status)
internal class AuthorizeRequest(requestObject: AuthorizeRequestObject): ObjectRequest<AuthorizeRequestObject>(requestObject)
internal class RejectRequest(requestObject: RejectRequestObject): ObjectRequest<RejectRequestObject>(requestObject)
internal class OperationClaimDetailRequest(requestObject: OperationClaimDetailData): ObjectRequest<OperationClaimDetailData>(requestObject)
internal class OperationClaimDetailResponse(responseObject: UserOperation, status: Status): ObjectResponse<UserOperation>(responseObject, status)

/**
* API for operations requests.
Expand All @@ -59,6 +61,8 @@ internal class OperationApi(
private val listEndpoint = EndpointSignedWithToken<EmptyRequest, OperationListResponse>("api/auth/token/app/operation/list", "possession_universal")
private val authorizeEndpoint = EndpointSigned<AuthorizeRequest, StatusResponse>("api/auth/token/app/operation/authorize", "/operation/authorize")
private val rejectEndpoint = EndpointSigned<RejectRequest, StatusResponse>("api/auth/token/app/operation/cancel", "/operation/cancel")
private val detailEndpoint = EndpointSignedWithToken<OperationClaimDetailRequest, OperationClaimDetailResponse>("api/auth/token/app/operation/detail", "possession_universal")
private val claimEndpoint = EndpointSignedWithToken<OperationClaimDetailRequest, OperationClaimDetailResponse>("api/auth/token/app/operation/detail/claim", "possession_universal")
const val OFFLINE_AUTHORIZE_URI_ID = "/operation/authorize/offline"
}

Expand All @@ -84,4 +88,14 @@ internal class OperationApi(
fun authorize(authorizeRequest: AuthorizeRequest, authentication: PowerAuthAuthentication, listener: IApiCallResponseListener<StatusResponse>) {
post(authorizeRequest, authorizeEndpoint, authentication, null, null, okHttpInterceptor, listener)
}

/** Get an operation detail. */
fun getDetail(claimRequest: OperationClaimDetailRequest, listener: IApiCallResponseListener<OperationClaimDetailResponse>) {
post(data = claimRequest, endpoint = detailEndpoint, headers = null, encryptor = null, okHttpInterceptor = okHttpInterceptor, listener = listener)
}

/** Claim an operation. */
fun claim(claimRequest: OperationClaimDetailRequest, listener: IApiCallResponseListener<OperationClaimDetailResponse>) {
post(data = claimRequest, endpoint = claimEndpoint, headers = null, encryptor = null, okHttpInterceptor = okHttpInterceptor, listener = listener)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023, Wultra s.r.o. (www.wultra.com).
*
* All rights reserved. This source code can be used only for purposes specified
* by the given license contract signed by the rightful deputy of Wultra s.r.o.
* This source code can be used only by the owner of the license.
*
* Any disputes arising in respect of this agreement (license) shall be brought
* before the Municipal Court of Prague.
*/

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

import com.google.gson.annotations.SerializedName

/**
* Model class for handling requests related to claiming and retrieving details of operations.
*
* @property operationId The unique identifier of the operation to be claimed or for which details are requested.
*/
internal data class OperationClaimDetailData(

@SerializedName("id")
val operationId: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ interface IOperationsService {
*/
fun getHistory(authentication: PowerAuthAuthentication, callback: (result: Result<List<OperationHistoryEntry>>) -> Unit)

/**
* Retrieves operation detail based on operation ID
*
* @param operationId The identifier of the specific operation.
* @param callback Callback with result.
*/
fun getDetail(operationId: String, callback: (Result<UserOperation>) -> Unit)

/**
* Claims the "non-personalized" operation and assigns it to the user.
*
* @param operationId Operation ID that will be claimed as belonging to the user.
* @param callback Callback with result.
*/
fun claim(operationId: String, callback: (Result<UserOperation>) -> Unit)

/**
* Returns if operation polling is running
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,39 @@ class OperationsService: IOperationsService {
?: throw Exception("Cannot sign this operation")
}

override fun getDetail(operationId: String, callback: (Result<UserOperation>) -> Unit) {
val detailRequest = OperationClaimDetailRequest(OperationClaimDetailData(operationId))

operationApi.getDetail(
detailRequest,
object : IApiCallResponseListener<OperationClaimDetailResponse> {
override fun onFailure(error: ApiError) {
callback(Result.failure(ApiErrorException(error)))
}

override fun onSuccess(result: OperationClaimDetailResponse) {
callback(Result.success(result.responseObject))
}
}
)
}

override fun claim(operationId: String, callback: (Result<UserOperation>) -> Unit) {
val claimRequest = OperationClaimDetailRequest(OperationClaimDetailData(operationId))
operationApi.claim(
claimRequest,
object : IApiCallResponseListener<OperationClaimDetailResponse> {
override fun onFailure(error: ApiError) {
callback(Result.failure(ApiErrorException(error)))
}

override fun onSuccess(result: OperationClaimDetailResponse) {
callback(Result.success(result.responseObject))
}
}
)
}

override fun isPollingOperations() = timer != null

@Synchronized
Expand Down

0 comments on commit d28abb5

Please sign in to comment.