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

PAC with claim #114

Merged
merged 10 commits into from
Jan 9, 2024
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 @@ -277,4 +280,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 @@ -190,6 +190,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 @@ -273,6 +299,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
Loading