Skip to content

Commit

Permalink
Handle OpenId Credential Offer Urls for dynamic Issuing Authorities.
Browse files Browse the repository at this point in the history
WalletServer and WalletServerState can create an IssuingAuthority by Uri
that impact Funke IssuingAuthorityState, ProofingState, RequestCredenti-
als state. WalletServerProvider is used for obtaining IssuingAuthority's
by Uri. Current PID-based approach Funke IssuingAuthority runs the same.

The function initiateCredentialOfferIssuance(.., issuer uri, config id)
enables any part of the app to get credentials from any arbitrary
Credential Issuer Uri as defined in the openid-credential-offer:// url.

Extracted ScanQrDialog into a standalone Composable that can be re-used
anywhere and obtain the scanned text. Refactored ReaderScreen to use the
new ScanQrDialog. AddToWallet also uses this dialog when user taps on
the -Scan Credential Offer- button.

Tested by:
- Manual tests from a emulator
- Manual tests from a real device
- ./gradlew check

Signed-off-by: dritan-x <[email protected]>
  • Loading branch information
dritan-x committed Sep 28, 2024
1 parent 86809c9 commit b21254f
Show file tree
Hide file tree
Showing 17 changed files with 648 additions and 230 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,15 @@ interface WalletServer: FlowBase {
*/
@FlowMethod
suspend fun getIssuingAuthority(identifier: String): IssuingAuthority

/**
* Create the Issuing Authority from a credential offer (haip) URI and credential config id.
*
* Like above, do not call this method directly, use WalletServerProvider that maintains a cache.
*/
@FlowMethod
suspend fun createIssuingAuthorityByUri(
credentialIssuerUri: String,
credentialConfigurationId: String
): IssuingAuthority
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ import kotlin.time.Duration.Companion.seconds
class FunkeIssuingAuthorityState(
val clientId: String,
val credentialFormat: CredentialFormat,
var issuanceClientId: String = "" // client id in OpenID4VCI protocol
var issuanceClientId: String = "", // client id in OpenID4VCI protocol
val credentialIssuerUri: String, // credential offer issuing authority path
) : AbstractIssuingAuthorityState() {
companion object {
private const val TAG = "FunkeIssuingAuthorityState"
Expand Down Expand Up @@ -203,7 +204,8 @@ class FunkeIssuingAuthorityState(
issuanceClientId = issuanceClientId,
documentId = documentId,
proofingInfo = proofingInfo,
applicationCapabilities = applicationCapabilities
applicationCapabilities = applicationCapabilities,
credentialIssuerUri = credentialIssuerUri
)
}

Expand Down Expand Up @@ -273,7 +275,13 @@ class FunkeIssuingAuthorityState(
)
)
}
return FunkeRequestCredentialsState(issuanceClientId, documentId, configuration, cNonce)
return FunkeRequestCredentialsState(
issuanceClientId,
documentId,
configuration,
cNonce,
credentialIssuerUri = credentialIssuerUri
)
}

@FlowJoin
Expand Down Expand Up @@ -314,7 +322,7 @@ class FunkeIssuingAuthorityState(
null -> throw IllegalStateException("Credential format was not specified")
}

val credentialUrl = "${FunkeUtil.BASE_URL}/credential"
val credentialUrl = "${credentialIssuerUri}/credential"
val access = document.access!!
val dpop = FunkeUtil.generateDPoP(
env,
Expand Down Expand Up @@ -473,8 +481,14 @@ class FunkeIssuingAuthorityState(
}

val clientKeyInfo = FunkeUtil.communicationKey(env, clientId)
val clientAssertion = applicationSupport?.createJwtClientAssertion(clientKeyInfo.attestation, FunkeUtil.BASE_URL) ?:
ApplicationSupportState(clientId).createJwtClientAssertion(env, clientKeyInfo.publicKey, FunkeUtil.BASE_URL )
val clientAssertion = applicationSupport?.createJwtClientAssertion(
clientKeyInfo.attestation,
credentialIssuerUri
) ?: ApplicationSupportState(clientId).createJwtClientAssertion(
env,
clientKeyInfo.publicKey,
credentialIssuerUri
)

issuanceClientId = extractIssuanceClientId(clientAssertion)

Expand All @@ -489,7 +503,7 @@ class FunkeIssuingAuthorityState(
add("client_id", issuanceClientId)
}
val httpClient = env.getInterface(HttpClient::class)!!
val response = httpClient.post("${FunkeUtil.BASE_URL}/par") {
val response = httpClient.post("${credentialIssuerUri}/par") {
headers {
append("Content-Type", "application/x-www-form-urlencoded")
}
Expand All @@ -508,7 +522,7 @@ class FunkeIssuingAuthorityState(
}
Logger.i(TAG, "Request uri: $requestUri")
return ProofingInfo(
authorizeUrl = "${FunkeUtil.BASE_URL}/authorize?" + FormUrlEncoder {
authorizeUrl = "${credentialIssuerUri}/authorize?" + FormUrlEncoder {
add("client_id", issuanceClientId)
add("request_uri", requestUri.content)
},
Expand Down Expand Up @@ -605,7 +619,7 @@ class FunkeIssuingAuthorityState(
env = env,
clientId = clientId,
issuanceClientId = issuanceClientId,
tokenUrl = "${FunkeUtil.BASE_URL}/token",
tokenUrl = "${credentialIssuerUri}/token",
refreshToken = refreshToken,
accessToken = access.accessToken
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ class FunkeProofingState(
var secureAreaIdentifier: String? = null,
var secureAreaSetupDone: Boolean = false,
var tosAcknowleged: Boolean = false,
var notificationPermissonRequested: Boolean = false
var notificationPermissonRequested: Boolean = false,
val credentialIssuerUri:String,
) {
companion object {
private const val TAG = "FunkeProofingState"
Expand Down Expand Up @@ -195,7 +196,7 @@ class FunkeProofingState(
val code = location.substring(index + 5)
this.access = FunkeUtil.obtainToken(
env = env,
tokenUrl = "${FunkeUtil.BASE_URL}/token",
tokenUrl = "${credentialIssuerUri}/token",
clientId = clientId,
issuanceClientId = issuanceClientId,
authorizationCode = code,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ class FunkeRequestCredentialsState(
val credentialConfiguration: CredentialConfiguration,
val nonce: String,
var format: CredentialFormat? = null,
var credentialRequests: List<FunkeCredentialRequest>? = null
var credentialRequests: List<FunkeCredentialRequest>? = null,
val credentialIssuerUri:String,
) {
companion object

Expand Down Expand Up @@ -55,7 +56,7 @@ class FunkeRequestCredentialsState(
)).toString().toByteArray().toBase64Url()
val body = JsonObject(mapOf(
"iss" to JsonPrimitive(issuanceClientId),
"aud" to JsonPrimitive(FunkeUtil.BASE_URL),
"aud" to JsonPrimitive(credentialIssuerUri),
"iat" to JsonPrimitive(Clock.System.now().epochSeconds),
"nonce" to JsonPrimitive(nonce)
)).toString().toByteArray().toBase64Url()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ internal object FunkeUtil {
// To use generic OpenID4VCI issuer, switch USE_AUSWEIS_SDK to false
//const val BASE_URL = "http://localhost:8080/server/openid4vci"

const val BASE_URL = "https://demo.pid-issuer.bundesdruckerei.de/c"

const val TAG = "FunkeUtil"

const val EU_PID_MDOC_DOCTYPE = "eu.europa.ec.eudi.pid.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import com.android.identity.flow.annotation.FlowState
import com.android.identity.flow.handler.FlowDispatcherLocal
import com.android.identity.flow.handler.FlowExceptionMap
import com.android.identity.flow.server.Configuration
import com.android.identity.flow.server.Resources
import com.android.identity.flow.server.FlowEnvironment
import com.android.identity.flow.server.Resources
import com.android.identity.issuance.ApplicationSupport
import com.android.identity.issuance.CredentialFormat
import com.android.identity.issuance.DocumentConfiguration
Expand All @@ -18,16 +18,16 @@ import com.android.identity.issuance.LandingUrlUnknownException
import com.android.identity.issuance.WalletServer
import com.android.identity.issuance.WalletServerSettings
import com.android.identity.issuance.common.AbstractIssuingAuthorityState
import com.android.identity.issuance.hardcoded.IssuingAuthorityState
import com.android.identity.issuance.hardcoded.ProofingState
import com.android.identity.issuance.hardcoded.RegistrationState
import com.android.identity.issuance.hardcoded.RequestCredentialsState
import com.android.identity.issuance.hardcoded.register
import com.android.identity.issuance.funke.FunkeIssuingAuthorityState
import com.android.identity.issuance.funke.FunkeProofingState
import com.android.identity.issuance.funke.FunkeRegistrationState
import com.android.identity.issuance.funke.FunkeRequestCredentialsState
import com.android.identity.issuance.funke.register
import com.android.identity.issuance.hardcoded.IssuingAuthorityState
import com.android.identity.issuance.hardcoded.ProofingState
import com.android.identity.issuance.hardcoded.RegistrationState
import com.android.identity.issuance.hardcoded.RequestCredentialsState
import com.android.identity.issuance.hardcoded.register
import com.android.identity.issuance.register
import kotlinx.io.bytestring.buildByteString
import kotlin.random.Random
Expand All @@ -43,6 +43,8 @@ class WalletServerState(
) {
companion object {
private const val TAG = "WalletServerState"
// Hard-coded Funke endpoint for PID issuer, could be /c or /c1
private const val FUNKE_BASE_URL = "https://demo.pid-issuer.bundesdruckerei.de/c"

private fun devConfig(env: FlowEnvironment): IssuingAuthorityConfiguration {
val resources = env.getInterface(Resources::class)!!
Expand Down Expand Up @@ -132,9 +134,46 @@ class WalletServerState(
fun getIssuingAuthority(env: FlowEnvironment, identifier: String): AbstractIssuingAuthorityState {
check(clientId.isNotEmpty())
return when (identifier) {
"funkeSdJwtVc" -> FunkeIssuingAuthorityState(clientId, CredentialFormat.SD_JWT_VC)
"funkeMdocMso" -> FunkeIssuingAuthorityState(clientId, CredentialFormat.MDOC_MSO)
"funkeSdJwtVc" -> FunkeIssuingAuthorityState(
clientId = clientId,
credentialFormat = CredentialFormat.SD_JWT_VC,
credentialIssuerUri = FUNKE_BASE_URL
)

"funkeMdocMso" -> FunkeIssuingAuthorityState(
clientId = clientId,
credentialFormat = CredentialFormat.MDOC_MSO,
credentialIssuerUri = FUNKE_BASE_URL
)

else -> IssuingAuthorityState(clientId, identifier)
}
}

/**
* Returns the Issuing Authority State with a specific issuing authority Uri for mdoc or sd-jwt
* credentials (such as from credential offer / haip deep link / qr code)
*/
@FlowMethod
fun createIssuingAuthorityByUri(
env: FlowEnvironment,
credentialIssuerUri: String,
credentialConfigurationId: String
): AbstractIssuingAuthorityState {
return when (credentialConfigurationId) {
"pid-sd-jwt" -> FunkeIssuingAuthorityState(
clientId = clientId,
credentialFormat = CredentialFormat.SD_JWT_VC,
credentialIssuerUri = credentialIssuerUri
)

"pid-mso-mdoc" -> FunkeIssuingAuthorityState(
clientId = clientId,
credentialFormat = CredentialFormat.MDOC_MSO,
credentialIssuerUri = credentialIssuerUri
)

else -> IssuingAuthorityState(clientId, credentialConfigurationId)
}
}
}
7 changes: 6 additions & 1 deletion wallet/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
tools:replace="android:name">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
Expand All @@ -49,7 +50,11 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="openwallet" android:host="callback" />
<data android:scheme="openwallet"/>
<data android:host="callback"/>
<!-- credential offer / haip -->
<data android:scheme="openid-credential-offer"/>
<data android:host="*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,22 @@ class WalletServerProvider(
}
}

/**
* Creates an Issuing Authority by the [credentialIssuerUri] and [credentialConfigurationId],
* caching instances. If unable to connect, suspend and wait until connecting is possible.
*/
suspend fun createIssuingAuthorityByUri(credentialIssuerUri:String, credentialConfigurationId: String): IssuingAuthority {
val instance = waitForWalletServer()
instanceLock.withLock {
var issuingAuthority = issuingAuthorityMap[credentialConfigurationId]
if (issuingAuthority == null) {
issuingAuthority = instance.createIssuingAuthorityByUri(credentialIssuerUri, credentialConfigurationId)
issuingAuthorityMap[credentialConfigurationId] = issuingAuthority
}
return issuingAuthority
}
}

/**
* Gets ApplicationSupport object. It always comes from the server (either full wallet server
* or minimal wallet server).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,26 @@
package com.android.identity_credential.wallet

import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import androidx.navigation.compose.rememberNavController
import com.android.identity_credential.wallet.credentialoffer.extractCredentialIssuerData
import com.android.identity_credential.wallet.credentialoffer.initiateCredentialOfferIssuance
import com.android.identity_credential.wallet.navigation.WalletNavigation
import com.android.identity_credential.wallet.navigation.navigateTo
import com.android.identity_credential.wallet.ui.theme.IdentityCredentialTheme
import java.net.URLDecoder

class MainActivity : FragmentActivity() {
companion object {
Expand Down Expand Up @@ -68,10 +76,10 @@ class MainActivity : FragmentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

application = getApplication() as WalletApplication

permissionTracker.updatePermissions()
// handle intents with schema openid-credential-offer://
handleCredentialOfferIntent(intent)

setContent {
IdentityCredentialTheme {
Expand All @@ -81,6 +89,30 @@ class MainActivity : FragmentActivity() {
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
// observe whether a new intent was received with credential offer url
val credentialOfferIntentPayload by provisioningViewModel.newCredentialOfferIntentReceived.collectAsState()
// if not null, execute once
LaunchedEffect(credentialOfferIntentPayload) {
if (credentialOfferIntentPayload != null) {
val credentialIssuerUri = credentialOfferIntentPayload!!.first
val credentialIssuerConfigurationId =
credentialOfferIntentPayload!!.second

initiateCredentialOfferIssuance(
walletServerProvider = application.walletServerProvider,
provisioningViewModel = provisioningViewModel,
settingsModel = application.settingsModel,
documentStore = application.documentStore,
onNavigate = { routeWithArgs ->
navigateTo(navController, routeWithArgs)
},
credentialIssuerUri = credentialIssuerUri,
credentialIssuerConfigurationId = credentialIssuerConfigurationId,
)
// reset the state (consume the Url)
provisioningViewModel.onNewCredentialOfferIntent(null, null)
}
}

WalletNavigation(
navController,
Expand All @@ -96,4 +128,35 @@ class MainActivity : FragmentActivity() {
}
}
}

/**
* Intercept haip/openid-credential-offer deep links and navigate to Credential Offer Screen with
* extracted arguments.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) // super class handles intent internally
setIntent(intent) // calls to getIntent() return the new intent
handleCredentialOfferIntent(intent) // handle intents with schema openid-credential-offer://
}

/**
* Handle incoming Intents from deep link emanating from onCreate() or onNewIntent()
*/
private fun handleCredentialOfferIntent(intent: Intent){
if (intent.action == Intent.ACTION_VIEW) {
// TODO implement credential_offer_uri https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-4.1.3

// perform recomposition only if deep link url starts with credential offer scheme
if (intent.dataString?.startsWith(resources.getString(R.string.credential_offer_uri_scheme)) == true) {
val decodedUrl = URLDecoder.decode(intent.dataString!!, "UTF-8")
val urlQueryComponent = decodedUrl.split("?")[1]
extractCredentialIssuerData(urlQueryComponent).let { (credentialIssuerUri, credentialConfigurationId) ->
provisioningViewModel.onNewCredentialOfferIntent(
credentialIssuerUri,
credentialConfigurationId
)
}
}
}
}
}
Loading

0 comments on commit b21254f

Please sign in to comment.