Skip to content

Custom module

Next to the modules provided and integrated by the Comuny, custom modules can be specified as well. This allows adding new service providers as well as your very own providers. Custom modules are handled the same way as pre-packaged ones, so you can specify claims to be resolved during authentication as well as resolving them explicitly. The data provided by your own claims is stored in the same data store as for all other claims.

Every module specifies 3 types of claims. Module claim defines "name" of the module. Be requesting this claim, all standard and specific claims which belongs to the module will be resolved. Specific claims defines all the claims, module can resolve. Each one has its specific name and value. Some of the specific claims can also fulfill Standard claim which is defined by OIDC specification.

All the standard claims are defines in sealed class de.comuny.wallet.core.dto.name.StandardClaimName with proper names and types. All specific claims extends from interface de.comuny.wallet.core.dto.name.SpecificClaimName and also from module specific sealed class.

Example of custom module implementation

In this example, we will create custom module called SampleModule To create custom module, there are several classes that needs to be implemented.

Module claim

First, we need definition of full module custom claim. Let's say, we want to have 2 values, timestamp and nickname

1
2
3
4
5
6
7
import de.comuny.wallet.core.dto.data.ModuleClaimValue

data class SampleModuleClaimValue(
    val ts: Long,
    val nickname: String,
    override val raw: String,
) : ModuleClaimValue

This class has to implement ModuleClaimValue interface and according to ours naming convention, we recommend to named it SampleModuleClaimValue.

Specific claims

Second step, you have to define specific claim, per each claim value in the module claim.

import de.comuny.wallet.core.dto.name.SpecificClaimName

sealed class SampleSpecificClaimName<T>(
    name: String
) : SpecificClaimName<T>(name)  {

    object SampleTimestamp : SampleSpecificClaimName<Long>("ns.ver.timestamp.l0.sample.sample")
    object SampleNickname : SampleSpecificClaimName<String>("ns.ver.nickname.l0.sample.sample")

    override fun toString(): String {
        return "SampleSpecificClaimName['$name']"
    }

    companion object {
        val values by lazy {
            setOf(
                SampleTimestamp,
                SampleNickname,
            )
        }
    }
}

As you can see, naming convention is similar to module claim, first module identification and SpecificClaimName afterwards. This time, it's called SampleSpecificClaimName and this class, has to be generic (so we can define type per each claim) and has to implement SpecificClaimName<T> interface. Another recommendation is, that this class should "look like" ENUM, so we implemented property values which is located in companion object.

In this example, we want to resolve current time stamp as first specific claim, which will be stored as Long, and Nickname of the user. Nickname claim is also resolvable as Standard claim (see OpenID documentation), and implementation of that is directly in module class, which you can see in Module definition section.

Module events system

Usually, module wants to inform the app, what's going on, or often it has to receive some kind of values, either from the app or even from the user. For these purpose, we have to implement module event structure. (How to use this events from module's perspective can be seen in Module definition section)

To create event structure, we have to create sealed class that implements ResolvingEvent.OnResolveModuleEvent interface, and we specify each event as a child of that sealed class. There are 2 types of events, informative events and requests.

Informative event can be implemented as object or data class, and it's purpose is to inform the app, that something is happening. We recommend defining Loading event, that will be fired every time module is doing or waiting for something.

To request any data from the user or the app, you have to create class with a function as a parameter, which will be triggered by the app. You can request any data you want, from android's context or activity, to any user specific data. When app received one of the request event, it can either directly respond to it or show the UI to the user.

import de.comuny.trinity.core.ResolvingEvent
import de.comuny.trinity.core.CancelEventException

sealed interface SampleModuleResolveEvent : ResolvingEvent.OnResolveModuleEvent {
    object OnSampleModuleLoading : SampleModuleResolveEvent {
        override fun toString(): String {
            return "OnSampleModuleLoading"
        }
    }

    class OnNicknameRequest(
        private val provideNickname: (Pair<String?, Exception?>) -> Unit
    ) : SampleModuleResolveEvent {
        fun continueResolving(nickname: String) = provideNickname.invoke(Pair(nickname, null))
        fun cancel(exception: Exception? = null) = provideNickname.invoke(Pair(null, exception ?: CancelEventException()))
        override fun toString(): String = "OnNicknameRequest"
    }
}

As you can see, we are defining sealed class SampleModuleResolveEvent which extends from ResolvingEvent.OnResolveModuleEvent. All the events extend from defined class.

OnSampleModuleLoading is an object, that serves as informative event, and it will be emitted every time module we want from app to show some kind of loading state.

OnNicknameRequest is a request event, which serves as a form of requesting the nickname from the user (or from app). Feel free to use any public properties or methods you case needs, but for getting the response from this request, you have to add at least one more private lambda property (doesn't have to be private, but implementation details should be hidden and exposed only by methods), which gets triggered by the app. We also recommend adding at least 2 methods, one for positive answer and one for negative.

If you create an object or class as an event we also recommend overriding toString method.

Module definition

Now it's time to tie it all together by definition of the module. This class is also responsible for resolving standard and specific claims.

import de.comuny.trinity.core.util.destructureValueOrThrowException
import de.comuny.trinity.core.util.waitForResponse
import de.comuny.wallet.core.dto.ClaimValue
import de.comuny.wallet.core.dto.name.ClaimName
import de.comuny.wallet.core.dto.name.ModuleClaimName
import de.comuny.wallet.core.dto.name.SpecificClaimName
import de.comuny.wallet.core.dto.name.StandardClaimName
import de.comuny.wallet.core.internal.SharedProps
import de.comuny.wallet.core.internal.TrinityLogger
import de.comuny.wallet.core.module.TrinityModule
import io.ktor.util.*
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.*
import kotlin.time.Duration.Companion.minutes

object SampleModule : TrinityModule<SampleModuleClaimValue, SampleModuleResolveEvent>() {

    override val moduleConfigLinkName: String = "sample"

    override val moduleClaim = ModuleClaimName("ns.ver.module.l0.sample.sample")
    override val standardClaims: Set<StandardClaimName<out Any>> = setOf(StandardClaimName.Nickname)
    override val specificClaims: Set<SpecificClaimName<out Any>> = SampleSpecificClaimName.values

    @OptIn(InternalAPI::class)
    override suspend fun resolveModule(claimName: ClaimName, configLink: String?, emitter: suspend (SampleModuleResolveEvent) -> Unit): String {
        // we can trigger loading at the start, to indicate module has started
        emitter(SampleModuleResolveEvent.OnSampleModuleLoading)
        // we can simulate some kind of work, usually, module will download configuration of some kind
        delay(300)

        // no we use request event, and get the value from it
        val nickname = destructureValueOrThrowException(
            waitForNickname(emitter)   // wait for response from user / ui
        )  // value from user / UI

        // loading is triggered immediately after receiving the value
        emitter(SampleModuleResolveEvent.OnSampleModuleLoading)

        // value created by module
        val ts = System.currentTimeMillis()
        delay(100)

        // construct the claim value
        val rawPayload = buildJsonObject {
            put("ts", ts)
            put("nickname", nickname)
        }.toString()
        return JwtHelper.sign(rawPayload)   // generate simple self signed JWS
    }

    /** This suspend function waits, until lambda get triggered by the app */
    private suspend fun waitForNickname(emitter: suspend (SampleModuleResolveEvent) -> Unit): Pair<String?, Exception?> {
        return try {
            waitForResponse(2.minutes) { responseFunction ->
                emitter(SampleModuleResolveEvent.OnNicknameRequest(responseFunction))
            }
        } catch (e: Exception) {
            TrinityLogger.printStackTrace(e)
            Pair(null, e)
        }
    }

    /**
     * This cancel methods will be called by the SDK in case that resolving of the module was canceled.
     * This is great place, if you need to inform your services about cancellation.
     */
    override suspend fun cancel() {
        println("resolving got cancelled")
    }

    override fun toString(): String = "SampleModule"

    @OptIn(InternalAPI::class)
    override fun getNormalizedModuleValue(claimValue: ClaimValue): SampleModuleClaimValue {
        val rawClaimValue = claimValue.jws
        val rawClaimPayload = JwtHelper.convertJwtToPayloadString(rawClaimValue)
        val jsonObject: JsonObject = SharedProps.props.json.decodeFromString(rawClaimPayload)
        return SampleModuleClaimValue(
            jsonObject.get("ts")!!.jsonPrimitive.long,
            jsonObject.get("nickname")!!.jsonPrimitive.content,
            rawClaimValue
        )
    }

    override fun <T> getNormalizedValue(standardClaim: StandardClaimName<T>, claimValue: ClaimValue): T {
        val moduleValue = getNormalizedModuleValue(claimValue)
        return when (standardClaim) {
            StandardClaimName.Nickname -> moduleValue.nickname
            else -> throw NotNormalizableClaimException(standardClaim)
        } as T
    }

    override fun <T> getNormalizedValue(specificClaimName: SpecificClaimName<T>, claimValue: ClaimValue): T {
        val moduleValue = getNormalizedModuleValue(claimValue)
        if (specificClaimName !is SampleSpecificClaimName)
            throw WrongClaimSpecificationException(specificClaimName)
        return when (specificClaimName) {
            SampleSpecificClaimName.SampleNickname -> moduleValue.nickname
            SampleSpecificClaimName.SampleTimestamp -> moduleValue.ts
        } as T
    }
}

Newly created module, has to be Kotlin object and has to extend TrinityModule and provide proper generic types, (module claim value and event structure). In our example, module is named SampleModule, it's module claim value is SampleModuleClaimValue and event structure is called SampleModuleResolveEvent.

This class also has to implement few properties and methods. moduleConfigLinkName identifies your module in OIDC configuration. Property moduleClaim represents the name of module claim. Set standardClaims contains all standard claims, that module can resolve. Set specificClaims contains all specific claims.

Method resolveModule contains all module's logic to resolve a claims. It can contain communication with backend, informing the app what is processing or requesting a data from the user. By calling emitter we can emit events to the app. Result of the resolving process, has to be properly signed JWS and it has to contain at least one of the x5c or x5u header value. If your backend doesn't support signing yet, and you are using development version of SDK, you can sign it by self generated keys using JwtHelper class.

Functions getNormalized(Module)Value serve as converters between data stored by SDK and what claims are requested. Keep in mind, getNormalizedValue methods has to be able to resolve all defined standard or specific claims.

If you want to use processIntegritytoken with your modules, extend de.comuny.wallet.core.module.TrinityModuleWithProcessIntegrityToken instead of TrinityModule and definition of ModuleClaimValue has to implement HasProcessIntegrityToken interface. Now your module functionresolveModule contains processIntegrityToken value. All other aspects of TrinityModule remains the same.