From ef558dc6fb0d776588b32904f1407a9690460a1d Mon Sep 17 00:00:00 2001 From: Peter Sorotokin Date: Wed, 18 Dec 2024 19:47:29 -0800 Subject: [PATCH] OpenId4VCI interoperability fixes. (#823) Signed-off-by: Peter Sorotokin --- ...e.kt => EvidenceRequestCredentialOffer.kt} | 2 +- .../EvidenceResponseCredentialOffer.kt | 8 + .../EvidenceResponsePreauthorizedCode.kt | 9 - .../evidence/Openid4VciCredentialOffer.kt | 48 +++ .../identity/issuance/funke/FunkeAccess.kt | 6 +- .../funke/FunkeIssuingAuthorityState.kt | 127 +------- .../issuance/funke/FunkeProofingState.kt | 300 ++++++++++++++---- .../identity/issuance/funke/FunkeUtil.kt | 2 +- .../funke/openid4VciIssuerMetadata.kt | 44 +-- .../com/android/identity/util/HtmlUtil.kt | 7 + .../identity/wallet/server/AdminServlet.kt | 14 +- .../wallet/ProvisioningViewModel.kt | 44 ++- .../CredentialOfferIssuance.kt | 82 +++-- .../ProvisionCredentialScreen.kt | 5 +- 14 files changed, 429 insertions(+), 269 deletions(-) rename identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/{EvidenceRequestPreauthorizedCode.kt => EvidenceRequestCredentialOffer.kt} (77%) create mode 100644 identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponseCredentialOffer.kt delete mode 100644 identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponsePreauthorizedCode.kt create mode 100644 identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/Openid4VciCredentialOffer.kt create mode 100644 identity/src/commonMain/kotlin/com/android/identity/util/HtmlUtil.kt diff --git a/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceRequestPreauthorizedCode.kt b/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceRequestCredentialOffer.kt similarity index 77% rename from identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceRequestPreauthorizedCode.kt rename to identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceRequestCredentialOffer.kt index 1b2ef5dac..5e8fc1826 100644 --- a/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceRequestPreauthorizedCode.kt +++ b/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceRequestCredentialOffer.kt @@ -4,4 +4,4 @@ package com.android.identity.issuance.evidence * Request pre-authorized code from the client. Pre-authorized code is given to a wallet app * as a part of an OpenId4VCI credential offer. */ -class EvidenceRequestPreauthorizedCode : EvidenceRequest() \ No newline at end of file +class EvidenceRequestCredentialOffer : EvidenceRequest() \ No newline at end of file diff --git a/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponseCredentialOffer.kt b/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponseCredentialOffer.kt new file mode 100644 index 000000000..92b30a16f --- /dev/null +++ b/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponseCredentialOffer.kt @@ -0,0 +1,8 @@ +package com.android.identity.issuance.evidence + +/** + * Provides OpenId4VCI credential offer data. + */ +data class EvidenceResponseCredentialOffer( + val credentialOffer: Openid4VciCredentialOffer +) : EvidenceResponse() \ No newline at end of file diff --git a/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponsePreauthorizedCode.kt b/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponsePreauthorizedCode.kt deleted file mode 100644 index 84df820ca..000000000 --- a/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/EvidenceResponsePreauthorizedCode.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.android.identity.issuance.evidence - -/** - * Provides OpenId4VCI pre-authorized code to the issuer. - */ -data class EvidenceResponsePreauthorizedCode( - val code: String, // preauthorized code - val txCode: String? // transaction code entered by the user (may or may not be required) -) : EvidenceResponse() \ No newline at end of file diff --git a/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/Openid4VciCredentialOffer.kt b/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/Openid4VciCredentialOffer.kt new file mode 100644 index 000000000..6820d3538 --- /dev/null +++ b/identity-issuance-api/src/commonMain/kotlin/com/android/identity/issuance/evidence/Openid4VciCredentialOffer.kt @@ -0,0 +1,48 @@ +package com.android.identity.issuance.evidence + +import com.android.identity.cbor.annotation.CborSerializable + +/** + * Credential offer as described in OpenId4Vci spec: + * https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer-parameters + */ +@CborSerializable +sealed class Openid4VciCredentialOffer( + val issuerUri: String, + val configurationId: String, + val authorizationServer: String? +) { + companion object +} + +/** + * Credential offer with Grant Type `urn:ietf:params:oauth:grant-type:pre-authorized_code`. + */ +class Openid4VciCredentialOfferPreauthorizedCode( + issuerUri: String, + configurationId: String, + authorizationServer: String?, + val preauthorizedCode: String, + val txCode: Openid4VciTxCode? +) : Openid4VciCredentialOffer(issuerUri, configurationId, authorizationServer) + +/** + * Credential offer with Grant Type `authorization_code`. + */ +class Openid4VciCredentialOfferAuthorizationCode( + issuerUri: String, + configurationId: String, + authorizationServer: String?, + val issuerState: String? +) : Openid4VciCredentialOffer(issuerUri, configurationId, authorizationServer) + + +/** + * Describes tx_code parameter (see OpenId4Vci spec referenced above). + */ +@CborSerializable +data class Openid4VciTxCode( + val description: String, + val isNumeric: Boolean, + val length: Int +) diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt index b799ed960..d56420c62 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeAccess.kt @@ -19,10 +19,11 @@ class FunkeAccess( val accessTokenExpiration: Instant, var dpopNonce: String?, var cNonce: String?, + var tokenEndpoint: String, var refreshToken: String?, ) { companion object { - suspend fun parseResponse(tokenResponse: HttpResponse): FunkeAccess { + suspend fun parseResponse(tokenEndpoint: String, tokenResponse: HttpResponse): FunkeAccess { val dpopNonce = tokenResponse.headers["DPoP-Nonce"] val tokenString = String(tokenResponse.readBytes()) val token = Json.parseToJsonElement(tokenString) as JsonObject @@ -31,7 +32,8 @@ class FunkeAccess( val duration = getField(token, "expires_in").long val accessTokenExpiration = Clock.System.now() + duration.seconds val refreshToken = token["refresh_token"]?.jsonPrimitive?.content - return FunkeAccess(accessToken, accessTokenExpiration, dpopNonce, cNonce, refreshToken) + return FunkeAccess(accessToken, accessTokenExpiration, dpopNonce, + cNonce, tokenEndpoint, refreshToken) } private fun getField(jsonElement: JsonObject, name: String): JsonPrimitive { diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt index 827ba38c4..575c4f50f 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt @@ -14,7 +14,6 @@ import com.android.identity.documenttype.DocumentTypeRepository import com.android.identity.documenttype.knowntypes.DrivingLicense import com.android.identity.documenttype.knowntypes.EUCertificateOfResidence import com.android.identity.documenttype.knowntypes.EUPersonalID -import com.android.identity.documenttype.knowntypes.GermanPersonalID import com.android.identity.documenttype.knowntypes.PhotoID import com.android.identity.documenttype.knowntypes.UtopiaNaturalization import com.android.identity.flow.annotation.FlowJoin @@ -187,7 +186,6 @@ class FunkeIssuingAuthorityState( val documentTypeRepository = DocumentTypeRepository().apply { addDocumentType(EUPersonalID.getDocumentType()) - addDocumentType(GermanPersonalID.getDocumentType()) addDocumentType(DrivingLicense.getDocumentType()) addDocumentType(PhotoID.getDocumentType()) addDocumentType(EUCertificateOfResidence.getDocumentType()) @@ -257,15 +255,6 @@ class FunkeIssuingAuthorityState( @FlowMethod suspend fun proof(env: FlowEnvironment, documentId: String): FunkeProofingState { val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - var openid4VpRequest: String? = null - val proofingInfo = performPushedAuthorizationRequest(env) - if (proofingInfo?.authSession != null && proofingInfo.openid4VpPresentation != null) { - val httpClient = env.getInterface(HttpClient::class)!! - val presentationResponse = httpClient.get(proofingInfo.openid4VpPresentation) {} - if (presentationResponse.status == HttpStatusCode.OK) { - openid4VpRequest = String(presentationResponse.readBytes()) - } - } val storage = env.getInterface(Storage::class)!! val applicationCapabilities = storage.get( "WalletApplicationCapabilities", @@ -274,19 +263,13 @@ class FunkeIssuingAuthorityState( )?.let { WalletApplicationCapabilities.fromCbor(it.toByteArray()) } ?: throw IllegalStateException("WalletApplicationCapabilities not found") - val useGermanId = metadata.authorizationServers.isNotEmpty() - && metadata.authorizationServers[0].useGermanEId return FunkeProofingState( clientId = clientId, credentialConfigurationId = credentialConfigurationId, issuanceClientId = issuanceClientId, documentId = documentId, credentialIssuerUri = credentialIssuerUri, - proofingInfo = proofingInfo, - applicationCapabilities = applicationCapabilities, - tokenUri = metadata.tokenEndpoint, - openid4VpRequest = openid4VpRequest, - useGermanEId = useGermanId + applicationCapabilities = applicationCapabilities ) } @@ -309,8 +292,8 @@ class FunkeIssuingAuthorityState( issuerDocument.state = DocumentCondition.READY if (issuerDocument.documentConfiguration == null) { val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - if (metadata.authorizationServers.isNotEmpty() && - metadata.authorizationServers[0].useGermanEId) { + if (metadata.authorizationServerList.isNotEmpty() && + metadata.authorizationServerList[0].useGermanEId) { val isCloudSecureArea = issuerDocument.secureAreaIdentifier!!.startsWith("CloudSecureArea?") issuerDocument.documentConfiguration = @@ -673,108 +656,6 @@ class FunkeIssuingAuthorityState( } } - private suspend fun performPushedAuthorizationRequest(env: FlowEnvironment): ProofingInfo? { - val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - if (metadata.authorizationServers.isEmpty()) { - return null - } - val config = metadata.credentialConfigurations[credentialConfigurationId]!! - val authorizationMetadata = metadata.authorizationServers[0] - val pkceCodeVerifier = Random.Default.nextBytes(32).toBase64Url() - val codeChallenge = Crypto.digest(Algorithm.SHA256, pkceCodeVerifier.toByteArray()).toBase64Url() - - // NB: applicationSupport will only be non-null when running this code locally in the - // Android Wallet app. - val applicationSupport = env.getInterface(ApplicationSupport::class) - val parRedirectUrl: String - val landingUrl: String - if (authorizationMetadata.useGermanEId) { - landingUrl = "" - // Does not matter, but must be https - parRedirectUrl = "https://secure.redirect.com" - } else { - landingUrl = applicationSupport?.createLandingUrl() ?: - ApplicationSupportState(clientId).createLandingUrl(env) - parRedirectUrl = landingUrl - } - - val clientKeyInfo = FunkeUtil.communicationKey(env, clientId) - val clientAssertion = if (applicationSupport != null) { - // Required when applicationSupport is exposed - val assertionMaker = env.getInterface(DeviceAssertionMaker::class)!! - applicationSupport.createJwtClientAssertion( - clientKeyInfo.attestation, - assertionMaker.makeDeviceAssertion(AssertionDPoPKey( - clientKeyInfo.publicKey, - credentialIssuerUri - )) - ) - } else { - ApplicationSupportState(clientId).createJwtClientAssertion( - env, - clientKeyInfo.publicKey, - credentialIssuerUri - ) - } - - val req = FormUrlEncoder { - add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-client-attestation") - if (config.scope != null) { - add("scope", config.scope) - } - add("response_type", "code") - add("code_challenge_method", "S256") - add("redirect_uri", parRedirectUrl) - add("client_assertion", clientAssertion) - add("code_challenge", codeChallenge) - add("client_id", issuanceClientId) - } - val httpClient = env.getInterface(HttpClient::class)!! - // Use authorization challenge if available, as we want to try it first before falling - // back to web-based authorization. - val (endpoint, expectedResponseStatus) = - if (authorizationMetadata.authorizationChallengeEndpoint != null) { - Pair( - authorizationMetadata.authorizationChallengeEndpoint, - HttpStatusCode.BadRequest - ) - } else { - Pair( - authorizationMetadata.pushedAuthorizationRequestEndpoint, - HttpStatusCode.Created - ) - } - val response = httpClient.post(endpoint) { - headers { - append("Content-Type", "application/x-www-form-urlencoded") - } - setBody(req.toString()) - } - val responseText = String(response.readBytes()) - if (response.status != expectedResponseStatus) { - Logger.e(TAG, "PAR request error: ${response.status}: $responseText") - throw IssuingAuthorityException("Error establishing authenticated channel with issuer") - } - val parsedResponse = Json.parseToJsonElement(responseText) as JsonObject - if (response.status == HttpStatusCode.BadRequest) { - val errorCode = parsedResponse["error"] - if (errorCode !is JsonPrimitive || errorCode.content != "insufficient_authorization") { - Logger.e(TAG, "PAR request error: ${response.status}: $responseText") - throw IssuingAuthorityException("Error establishing authenticated channel with issuer") - } - } - val authSession = parsedResponse["auth_session"] - val requestUri = parsedResponse["request_uri"] - val presentation = parsedResponse["presentation"] - return ProofingInfo( - requestUri = requestUri?.jsonPrimitive?.content, - authSession = authSession?.jsonPrimitive?.content, - pkceCodeVerifier = pkceCodeVerifier, - landingUrl = landingUrl, - openid4VpPresentation = presentation?.jsonPrimitive?.content - ) - } - private suspend fun generateFunkeDocumentConfiguration( env: FlowEnvironment, isCloudSecureArea: Boolean @@ -904,7 +785,7 @@ class FunkeIssuingAuthorityState( env = env, clientId = clientId, issuanceClientId = issuanceClientId, - tokenUrl = metadata.tokenEndpoint, + tokenUrl = access.tokenEndpoint, refreshToken = refreshToken, accessToken = access.accessToken ) diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt index 769f8d906..36d5ea4fa 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeProofingState.kt @@ -1,11 +1,16 @@ package com.android.identity.issuance.funke import com.android.identity.cbor.annotation.CborSerializable +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.device.AssertionDPoPKey +import com.android.identity.device.DeviceAssertionMaker import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.Configuration 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.IssuingAuthorityException import com.android.identity.issuance.ProofingFlow import com.android.identity.issuance.WalletApplicationCapabilities @@ -15,8 +20,9 @@ import com.android.identity.issuance.evidence.EvidenceRequestGermanEid import com.android.identity.issuance.evidence.EvidenceRequestMessage import com.android.identity.issuance.evidence.EvidenceRequestNotificationPermission import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp -import com.android.identity.issuance.evidence.EvidenceRequestPreauthorizedCode +import com.android.identity.issuance.evidence.EvidenceRequestCredentialOffer import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice +import com.android.identity.issuance.evidence.EvidenceRequestQuestionString import com.android.identity.issuance.evidence.EvidenceRequestSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceRequestWeb import com.android.identity.issuance.evidence.EvidenceResponse @@ -24,13 +30,20 @@ import com.android.identity.issuance.evidence.EvidenceResponseGermanEid import com.android.identity.issuance.evidence.EvidenceResponseMessage import com.android.identity.issuance.evidence.EvidenceResponseNotificationPermission import com.android.identity.issuance.evidence.EvidenceResponseOpenid4Vp -import com.android.identity.issuance.evidence.EvidenceResponsePreauthorizedCode +import com.android.identity.issuance.evidence.EvidenceResponseCredentialOffer import com.android.identity.issuance.evidence.EvidenceResponseQuestionMultipleChoice +import com.android.identity.issuance.evidence.EvidenceResponseQuestionString import com.android.identity.issuance.evidence.EvidenceResponseSetupCloudSecureArea import com.android.identity.issuance.evidence.EvidenceResponseWeb +import com.android.identity.issuance.evidence.Openid4VciCredentialOffer +import com.android.identity.issuance.evidence.Openid4VciCredentialOfferAuthorizationCode +import com.android.identity.issuance.evidence.Openid4VciCredentialOfferPreauthorizedCode +import com.android.identity.issuance.wallet.ApplicationSupportState import com.android.identity.securearea.PassphraseConstraints import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url +import com.android.identity.util.htmlEscape +import com.android.identity.util.toBase64Url import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.headers @@ -39,10 +52,13 @@ import io.ktor.client.request.setBody import io.ktor.client.statement.readBytes import io.ktor.http.HttpStatusCode import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.net.URI import java.net.URLEncoder +import kotlin.random.Random @FlowState( flowInterface = ProofingFlow::class @@ -54,16 +70,17 @@ class FunkeProofingState( val clientId: String, val issuanceClientId: String, val documentId: String, - val proofingInfo: ProofingInfo?, val applicationCapabilities: WalletApplicationCapabilities, - val tokenUri: String, - val useGermanEId: Boolean = false, + var proofingInfo: ProofingInfo? = null, + var useGermanEId: Boolean = false, var access: FunkeAccess? = null, var secureAreaIdentifier: String? = null, var secureAreaSetupDone: Boolean = false, var tosAcknowleged: Boolean = false, var notificationPermissonRequested: Boolean = false, - var openid4VpRequest: String? = null + var openid4VpRequest: String? = null, + var txCode: String? = null, + var credentialOffer: Openid4VciCredentialOffer? = null ) { companion object { private const val TAG = "FunkeProofingState" @@ -73,8 +90,12 @@ class FunkeProofingState( suspend fun getEvidenceRequests(env: FlowEnvironment): List { val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) val configuration = metadata.credentialConfigurations[credentialConfigurationId]!! + val credentialOffer = this.credentialOffer return if (access == null) { - if (!tosAcknowleged) { + // Don't have access token yet. + if (credentialOffer == null) { + listOf(EvidenceRequestCredentialOffer()) + } else if (!tosAcknowleged) { val message = if (useGermanEId) { env.getInterface(Resources::class)!! .getStringResource("funke/tos.html")!! @@ -86,18 +107,49 @@ class FunkeProofingState( .replace("\$ISSUER_NAME", issuingAuthorityName) .replace("\$ID_NAME", documentName) } - listOf( - EvidenceRequestMessage( - message = message, - assets = emptyMap(), - acceptButtonText = "Continue", - rejectButtonText = "Cancel" - ) - ) - } else if (!notificationPermissonRequested) { - listOf( - EvidenceRequestNotificationPermission( - permissionNotGrantedMessage = """ + listOf(EvidenceRequestMessage( + message = message, + assets = emptyMap(), + acceptButtonText = "Continue", + rejectButtonText = "Cancel" + )) + } else if (txCode == null && + credentialOffer is Openid4VciCredentialOfferPreauthorizedCode && + credentialOffer.txCode != null) { + val message = "

" + credentialOffer.txCode!!.description.htmlEscape() + "

" + listOf(EvidenceRequestQuestionString( + message = message, + assets = mapOf(), + defaultValue = "", + acceptButtonText = "OK" + )) + } else { + val list = mutableListOf() + val authorizationMetadata = selectAuthorizationServer(metadata) + val proofingInfo = this.proofingInfo + if (openid4VpRequest != null && + authorizationMetadata.authorizationChallengeEndpoint != null) { + val uri = URI(authorizationMetadata.authorizationChallengeEndpoint) + val origin = uri.scheme + ":" + uri.authority + list.add(EvidenceRequestOpenid4Vp(origin, openid4VpRequest!!)) + } + if (proofingInfo != null && authorizationMetadata.authorizationEndpoint != null) { + val authorizeUrl = + "${authorizationMetadata.authorizationEndpoint}?" + FormUrlEncoder { + add("client_id", issuanceClientId) + add("request_uri", proofingInfo.requestUri!!) + } + if (authorizationMetadata.useGermanEId) { + list.add(EvidenceRequestGermanEid(authorizeUrl, listOf())) + } else { + list.add(EvidenceRequestWeb(authorizeUrl, proofingInfo.landingUrl)) + } + } + list + } + } else if (!notificationPermissonRequested) { + listOf(EvidenceRequestNotificationPermission( + permissionNotGrantedMessage = """ ## Receive notifications? If there are updates to your document the issuer will send an updated document @@ -112,29 +164,7 @@ class FunkeProofingState( continueWithoutPermissionButtonText = "No Thanks", assets = mapOf() ) - ) - } else { - val list = mutableListOf(EvidenceRequestPreauthorizedCode()) - if (proofingInfo != null && metadata.authorizationServers.isNotEmpty()) { - val authorizationMetadata = metadata.authorizationServers[0] - val authorizeUrl = - "${authorizationMetadata.authorizationEndpoint}?" + FormUrlEncoder { - add("client_id", issuanceClientId) - add("request_uri", proofingInfo.requestUri!!) - } - if (authorizationMetadata.useGermanEId) { - list.add(EvidenceRequestGermanEid(authorizeUrl, listOf())) - } else { - if (openid4VpRequest != null) { - val uri = URI(authorizationMetadata.authorizationChallengeEndpoint!!) - val origin = uri.scheme + "://" + uri.authority - list.add(EvidenceRequestOpenid4Vp(origin, openid4VpRequest!!)) - } - list.add(EvidenceRequestWeb(authorizeUrl, proofingInfo.landingUrl)) - } - } - return list - } + ) } else if (configuration.proofType == Openid4VciNoProof) { // Keyless credentials, no more questions emptyList() @@ -177,6 +207,20 @@ class FunkeProofingState( @FlowMethod suspend fun sendEvidence(env: FlowEnvironment, evidenceResponse: EvidenceResponse) { when (evidenceResponse) { + is EvidenceResponseCredentialOffer -> { + val credentialOffer = evidenceResponse.credentialOffer + this.credentialOffer = credentialOffer + initializeProofing(env) + if (credentialOffer is Openid4VciCredentialOfferPreauthorizedCode) { + if (credentialOffer.txCode == null) { + obtainTokenUsingPreauthorizedCode(env) + } + } + } + is EvidenceResponseQuestionString -> { + txCode = evidenceResponse.answer + obtainTokenUsingPreauthorizedCode(env) + } is EvidenceResponseGermanEid -> { val url = evidenceResponse.url if (url != null) { @@ -217,19 +261,6 @@ class FunkeProofingState( is EvidenceResponseOpenid4Vp -> { processOpenid4VpResponse(env, evidenceResponse.response) } - is EvidenceResponsePreauthorizedCode -> { - this.access = FunkeUtil.obtainToken( - env = env, - tokenUrl = tokenUri, - clientId = clientId, - issuanceClientId = issuanceClientId, - preauthorizedCode = evidenceResponse.code, - txCode = evidenceResponse.txCode, - codeVerifier = proofingInfo?.pkceCodeVerifier, - dpopNonce = null - ) - Logger.i(TAG, "Token request: success") - } else -> throw IllegalArgumentException("Unexpected evidence type") } } @@ -271,7 +302,7 @@ class FunkeProofingState( val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) this.access = FunkeUtil.obtainToken( env = env, - tokenUrl = metadata.authorizationServers[0].tokenEndpoint, + tokenUrl = selectAuthorizationServer(metadata).tokenEndpoint, clientId = clientId, issuanceClientId = issuanceClientId, authorizationCode = authCode, @@ -281,6 +312,21 @@ class FunkeProofingState( Logger.i(TAG, "Token request: success") } + private suspend fun obtainTokenUsingPreauthorizedCode(env: FlowEnvironment) { + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + this.access = FunkeUtil.obtainToken( + env = env, + tokenUrl = selectAuthorizationServer(metadata).tokenEndpoint, + clientId = clientId, + issuanceClientId = issuanceClientId, + preauthorizedCode = (credentialOffer as Openid4VciCredentialOfferPreauthorizedCode).preauthorizedCode, + txCode = txCode, + codeVerifier = proofingInfo?.pkceCodeVerifier, + dpopNonce = null + ) + Logger.i(TAG, "Token request: success") + } + private suspend fun processOpenid4VpResponse(env: FlowEnvironment, response: String) { val body = String(openid4VpRequest!!.split('.')[1].fromBase64Url()) val url = Json.parseToJsonElement(body).jsonObject["response_uri"]!!.jsonPrimitive.content @@ -299,7 +345,7 @@ class FunkeProofingState( val presentationCode = parsedResponse["presentation_during_issuance_session"]!!.jsonPrimitive.content val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - val authorizationMetadata = metadata.authorizationServers[0] + val authorizationMetadata = selectAuthorizationServer(metadata) val dpop = FunkeUtil.generateDPoP(env, clientId, authorizationMetadata.authorizationChallengeEndpoint!!, null, null) val challengeRequest = FormUrlEncoder { @@ -322,4 +368,142 @@ class FunkeProofingState( val authCode = parsedChallengeResponse["authorization_code"]!!.jsonPrimitive.content obtainTokenUsingCode(env, authCode, null) } -} \ No newline at end of file + + private fun selectAuthorizationServer( + metadata: Openid4VciIssuerMetadata + ): Openid4VciAuthorizationMetadata { + val authorizationServer = credentialOffer?.authorizationServer + return if (authorizationServer == null) { + metadata.authorizationServerList[0] + } else { + metadata.authorizationServerList.first { it -> + it.baseUrl == authorizationServer + } + } + } + + private suspend fun initializeProofing(env: FlowEnvironment) { + performPushedAuthorizationRequest(env) + val proofingInfo = this.proofingInfo + if (proofingInfo?.authSession != null && proofingInfo.openid4VpPresentation != null) { + val httpClient = env.getInterface(HttpClient::class)!! + val presentationResponse = httpClient.get(proofingInfo.openid4VpPresentation) {} + if (presentationResponse.status == HttpStatusCode.OK) { + openid4VpRequest = String(presentationResponse.readBytes()) + } + } + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + useGermanEId = selectAuthorizationServer(metadata).useGermanEId + } + + private suspend fun performPushedAuthorizationRequest(env: FlowEnvironment) { + if (credentialOffer is Openid4VciCredentialOfferPreauthorizedCode) { + return + } + val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) + val config = metadata.credentialConfigurations[credentialConfigurationId]!! + val authorizationMetadata = selectAuthorizationServer(metadata) + // Use authorization challenge if available, as we want to try it first before falling + // back to web-based authorization. + val (endpoint, expectedResponseStatus) = + if (authorizationMetadata.authorizationChallengeEndpoint != null) { + Pair( + authorizationMetadata.authorizationChallengeEndpoint, + HttpStatusCode.BadRequest + ) + } else if (authorizationMetadata.pushedAuthorizationRequestEndpoint != null) { + Pair( + authorizationMetadata.pushedAuthorizationRequestEndpoint, + HttpStatusCode.Created + ) + } else { + return + } + val pkceCodeVerifier = Random.Default.nextBytes(32).toBase64Url() + val codeChallenge = Crypto.digest(Algorithm.SHA256, pkceCodeVerifier.toByteArray()).toBase64Url() + + // NB: applicationSupport will only be non-null when running this code locally in the + // Android Wallet app. + val applicationSupport = env.getInterface(ApplicationSupport::class) + val parRedirectUrl: String + val landingUrl: String + if (authorizationMetadata.useGermanEId) { + landingUrl = "" + // Does not matter, but must be https + parRedirectUrl = "https://secure.redirect.com" + } else { + landingUrl = applicationSupport?.createLandingUrl() ?: + ApplicationSupportState(clientId).createLandingUrl(env) + parRedirectUrl = landingUrl + } + + val clientKeyInfo = FunkeUtil.communicationKey(env, clientId) + val clientAssertion = if (applicationSupport != null) { + // Required when applicationSupport is exposed + val assertionMaker = env.getInterface(DeviceAssertionMaker::class)!! + applicationSupport.createJwtClientAssertion( + clientKeyInfo.attestation, + assertionMaker.makeDeviceAssertion(AssertionDPoPKey( + clientKeyInfo.publicKey, + credentialIssuerUri + )) + ) + } else { + ApplicationSupportState(clientId).createJwtClientAssertion( + env, + clientKeyInfo.publicKey, + credentialIssuerUri + ) + } + + val credentialOffer = this.credentialOffer + val req = FormUrlEncoder { + add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-client-attestation") + if (config.scope != null) { + add("scope", config.scope) + } + if (credentialOffer is Openid4VciCredentialOfferAuthorizationCode) { + val issuerState = credentialOffer.issuerState + if (issuerState != null) { + add("issuer_state", issuerState) + } + } + add("response_type", "code") + add("code_challenge_method", "S256") + add("redirect_uri", parRedirectUrl) + add("client_assertion", clientAssertion) + add("code_challenge", codeChallenge) + add("client_id", issuanceClientId) + } + val httpClient = env.getInterface(HttpClient::class)!! + val response = httpClient.post(endpoint) { + headers { + append("Content-Type", "application/x-www-form-urlencoded") + } + setBody(req.toString()) + } + val responseText = String(response.readBytes()) + if (response.status != expectedResponseStatus) { + Logger.e(TAG, "PAR request error: ${response.status}: $responseText") + throw IssuingAuthorityException("Error establishing authenticated channel with issuer") + } + val parsedResponse = Json.parseToJsonElement(responseText) as JsonObject + if (response.status == HttpStatusCode.BadRequest) { + val errorCode = parsedResponse["error"] + if (errorCode !is JsonPrimitive || errorCode.content != "insufficient_authorization") { + Logger.e(TAG, "PAR request error: ${response.status}: $responseText") + throw IssuingAuthorityException("Error establishing authenticated channel with issuer") + } + } + val authSession = parsedResponse["auth_session"] + val requestUri = parsedResponse["request_uri"] + val presentation = parsedResponse["presentation"] + this.proofingInfo = ProofingInfo( + requestUri = requestUri?.jsonPrimitive?.content, + authSession = authSession?.jsonPrimitive?.content, + pkceCodeVerifier = pkceCodeVerifier, + landingUrl = landingUrl, + openid4VpPresentation = presentation?.jsonPrimitive?.content + ) + } +} diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt index 3c3bf975a..77ae7bdc3 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt @@ -153,7 +153,7 @@ internal object FunkeUtil { ) } return try { - FunkeAccess.parseResponse(tokenResponse) + FunkeAccess.parseResponse(tokenUrl, tokenResponse) } catch (err: IllegalArgumentException) { val tokenString = String(tokenResponse.readBytes()) Logger.e(TAG, "Invalid token response: ${err.message}: $tokenString") diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt index 5ed882320..b184e8a96 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt @@ -13,6 +13,8 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -23,8 +25,7 @@ internal data class Openid4VciIssuerMetadata( val credentialEndpoint: String, val display: List, val credentialConfigurations: Map, - val authorizationServers: List, - val tokenEndpoint: String // fallback token endpoint, not standard + val authorizationServerList: List ) { companion object { const val TAG = "Openid4VciIssuerMetadata" @@ -60,25 +61,24 @@ internal data class Openid4VciIssuerMetadata( val authorizationMetadataText = String(authorizationMetadataRequest.readBytes()) val authorizationMetadata = extractAuthorizationServerMetadata( - Json.parseToJsonElement(authorizationMetadataText).jsonObject) + url = authorizationServerUrl, + jsonObject = Json.parseToJsonElement(authorizationMetadataText).jsonObject + ) if (authorizationMetadata != null) { authorizationMetadataList.add(authorizationMetadata) } } - val tokenEndpoint = if (authorizationMetadataList.isEmpty()) { - credentialMetadata["token_endpoint"]?.jsonPrimitive?.content - ?: "$issuerUrl/token" - } else { - authorizationMetadataList[0].tokenEndpoint - } Openid4VciIssuerMetadata( credentialIssuer = credentialMetadata["credential_issuer"]?.jsonPrimitive?.content ?: issuerUrl, credentialEndpoint = credentialMetadata["credential_endpoint"]!!.jsonPrimitive.content, display = extractDisplay(credentialMetadata["display"]), - authorizationServers = authorizationMetadataList.toList(), + authorizationServerList = authorizationMetadataList.toList(), credentialConfigurations = credentialMetadata["credential_configurations_supported"]!!.jsonObject.mapValues { val obj = it.value.jsonObject + val credentialSigningAlgorithms = + obj["credential_signing_alg_values_supported"] + ?: obj["cryptographic_suites_supported"]!! // Deprecated name Openid4VciCredentialConfiguration( id = it.key, scope = obj["scope"]?.jsonPrimitive?.content, @@ -87,15 +87,14 @@ internal data class Openid4VciIssuerMetadata( SUPPORTED_BINDING_METHODS ), credentialSigningAlgorithm = preferred( - obj["credential_signing_alg_values_supported"]!!.jsonArray, + credentialSigningAlgorithms.jsonArray, SUPPORTED_SIGNATURE_ALGORITHMS ), proofType = extractProofType(obj["proof_types_supported"]?.jsonObject), format = extractFormat(obj), display = extractDisplay(obj["display"]) ) - }, - tokenEndpoint = tokenEndpoint + } ) } } @@ -123,7 +122,10 @@ internal data class Openid4VciIssuerMetadata( } // Returns null if no compatible configuration could be created - private fun extractAuthorizationServerMetadata(jsonObject: JsonObject): Openid4VciAuthorizationMetadata? { + private fun extractAuthorizationServerMetadata( + url: String, + jsonObject: JsonObject + ): Openid4VciAuthorizationMetadata? { val responseType = preferred( jsonObject["response_types_supported"]?.jsonArray, SUPPORTED_RESPONSE_TYPES @@ -136,18 +138,21 @@ internal data class Openid4VciIssuerMetadata( jsonObject["dpop_signing_alg_values_supported"]?.jsonArray, SUPPORTED_SIGNATURE_ALGORITHMS ) ?: return null - val authorizationEndpoint = jsonObject["authorization_endpoint"]!!.jsonPrimitive.content + val authorizationEndpoint = jsonObject["authorization_endpoint"]?.jsonPrimitive?.content val authorizationChallengeEndpoint = jsonObject["authorization_challenge_endpoint"]?.jsonPrimitive?.content + val useGermanEId = authorizationEndpoint != null && + authorizationEndpoint.startsWith("https://demo.pid-issuer.bundesdruckerei.de/") return Openid4VciAuthorizationMetadata( - pushedAuthorizationRequestEndpoint = jsonObject["pushed_authorization_request_endpoint"]!!.jsonPrimitive.content, + baseUrl = url, + pushedAuthorizationRequestEndpoint = jsonObject["pushed_authorization_request_endpoint"]?.jsonPrimitive?.content, authorizationEndpoint = authorizationEndpoint, authorizationChallengeEndpoint = authorizationChallengeEndpoint, tokenEndpoint = jsonObject["token_endpoint"]!!.jsonPrimitive.content, responseType = responseType, codeChallengeMethod = codeChallengeMethod, dpopSigningAlgorithm = dpopSigningAlgorithm, - useGermanEId = authorizationEndpoint.startsWith("https://demo.pid-issuer.bundesdruckerei.de/") + useGermanEId = useGermanEId ) } @@ -196,8 +201,9 @@ internal data class Openid4VciIssuerMetadata( // from .well-known/oauth-authorization-server internal data class Openid4VciAuthorizationMetadata( - val pushedAuthorizationRequestEndpoint: String, - val authorizationEndpoint: String, + val baseUrl: String, + val pushedAuthorizationRequestEndpoint: String?, + val authorizationEndpoint: String?, val authorizationChallengeEndpoint: String?, val tokenEndpoint: String, val responseType: String, diff --git a/identity/src/commonMain/kotlin/com/android/identity/util/HtmlUtil.kt b/identity/src/commonMain/kotlin/com/android/identity/util/HtmlUtil.kt new file mode 100644 index 000000000..bf5cbe346 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/util/HtmlUtil.kt @@ -0,0 +1,7 @@ +package com.android.identity.util + +fun String.htmlEscape(): String { + return replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} \ No newline at end of file diff --git a/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt b/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt index 562bd1c7b..69ddd4871 100644 --- a/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt @@ -13,6 +13,7 @@ import com.android.identity.issuance.hardcoded.IssuerDocument import com.android.identity.issuance.hardcoded.IssuingAuthorityState import com.android.identity.server.BaseHttpServlet import com.android.identity.util.Logger +import com.android.identity.util.htmlEscape import io.ktor.utils.io.core.toByteArray import jakarta.servlet.http.Cookie import kotlinx.coroutines.runBlocking @@ -189,13 +190,6 @@ class AdminServlet : BaseHttpServlet() { } } - private fun htmlEscape(text: String?): String { - return (text ?: "") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - } - override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { val threadId = Thread.currentThread().id val remoteHost = getRemoteHost(req) @@ -223,10 +217,10 @@ class AdminServlet : BaseHttpServlet() { val documentIds = storage.enumerate("IssuerDocument", clientId) for (documentId in documentIds) { writer.write("") - writer.write("${htmlEscape(documentId)}") + writer.write("${documentId.htmlEscape()}") val documentData = storage.get("IssuerDocument", clientId, documentId)!! val document = IssuerDocument.fromDataItem(Cbor.decode(documentData.toByteArray())) - writer.write("${htmlEscape(document.documentConfiguration?.displayName)}") + writer.write("${document.documentConfiguration?.displayName?.htmlEscape()}") writer.write("") } } @@ -241,7 +235,7 @@ class AdminServlet : BaseHttpServlet() { runBlocking { val clients = storage.enumerate("ClientKeys", "") for (client in clients) { - val escaped = htmlEscape(client) + val escaped = client.htmlEscape() val urlenc = URLEncoder.encode(client, "utf-8") writer.write("
  • $escaped
  • ") } diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt index d58104635..613a78bf8 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt @@ -18,10 +18,12 @@ import com.android.identity.issuance.RegistrationResponse import com.android.identity.issuance.evidence.EvidenceRequest import com.android.identity.issuance.evidence.EvidenceRequestIcaoNfcTunnel import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp -import com.android.identity.issuance.evidence.EvidenceRequestPreauthorizedCode +import com.android.identity.issuance.evidence.EvidenceRequestCredentialOffer import com.android.identity.issuance.evidence.EvidenceResponse import com.android.identity.issuance.evidence.EvidenceResponseIcaoNfcTunnel -import com.android.identity.issuance.evidence.EvidenceResponsePreauthorizedCode +import com.android.identity.issuance.evidence.EvidenceResponseCredentialOffer +import com.android.identity.issuance.evidence.Openid4VciCredentialOffer +import com.android.identity.issuance.evidence.Openid4VciCredentialOfferAuthorizationCode import com.android.identity.issuance.remote.WalletServerProvider import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url @@ -103,10 +105,10 @@ class ProvisioningViewModel : ViewModel() { this.job = viewModelScope.launch(Dispatchers.IO) { lastJob?.join() reset() - this@ProvisioningViewModel.openid4VciCredentialOffer = openid4VciCredentialOffer state.value = State.IDLE try { val issuer = if (openid4VciCredentialOffer != null) { + this@ProvisioningViewModel.openid4VciCredentialOffer = openid4VciCredentialOffer walletServerProvider.createOpenid4VciIssuingAuthorityByUri( openid4VciCredentialOffer.issuerUri, openid4VciCredentialOffer.configurationId @@ -142,6 +144,16 @@ class ProvisioningViewModel : ViewModel() { proofingFlow = issuer.proof(issuerDocumentIdentifier) evidenceRequests = proofingFlow!!.getEvidenceRequests() + // EvidenceRequestCredentialOffer (if requested at all) is always the first request. + if (evidenceRequests!!.isNotEmpty() && + evidenceRequests!![0] is EvidenceRequestCredentialOffer) { + proofingFlow!!.sendEvidence( + EvidenceResponseCredentialOffer(openid4VciCredentialOffer ?: + createDefaultCredentialOffer(issuerIdentifier!!)) + ) + evidenceRequests = proofingFlow!!.getEvidenceRequests() + } + currentEvidenceRequestIndex = 0 Logger.d(TAG, "ers0 ${evidenceRequests!!.size}") @@ -166,6 +178,12 @@ class ProvisioningViewModel : ViewModel() { } } + private fun createDefaultCredentialOffer(issuerIdentifier: String): Openid4VciCredentialOffer { + val parts = issuerIdentifier.split('#') + return Openid4VciCredentialOfferAuthorizationCode( + parts[1], parts[2], null, null) + } + fun evidenceCollectionFailed( error: Throwable ) { if (document != null) { @@ -194,20 +212,6 @@ class ProvisioningViewModel : ViewModel() { proofingFlow!!.complete() document!!.refreshState(walletServerProvider) } else { - if (evidenceRequests!![0] is EvidenceRequestPreauthorizedCode) { - if (openid4VciCredentialOffer?.preauthorizedCode != null) { - Logger.d(TAG, "handling pre-authorized code") - proofingFlow!!.sendEvidence( - EvidenceResponsePreauthorizedCode( - code = openid4VciCredentialOffer!!.preauthorizedCode!!, - txCode = null // We don't support it yet - ) - ) - evidenceRequests = proofingFlow!!.getEvidenceRequests() - } else { - currentEvidenceRequestIndex++ - } - } selectViableEvidenceRequest() state.value = State.EVIDENCE_REQUESTS_READY } @@ -360,10 +364,4 @@ class ProvisioningViewModel : ViewModel() { CredentialFormat.SD_JWT_VC -> documentConfiguration.sdJwtVcDocumentConfiguration != null } } - - data class Openid4VciCredentialOffer( - val issuerUri: String, - val configurationId: String, - val preauthorizedCode: String? - ) } \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt b/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt index 551b127ae..33b9ccbb1 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/credentialoffer/CredentialOfferIssuance.kt @@ -1,5 +1,9 @@ package com.android.identity_credential.wallet.credentialoffer +import com.android.identity.issuance.evidence.Openid4VciCredentialOffer +import com.android.identity.issuance.evidence.Openid4VciCredentialOfferAuthorizationCode +import com.android.identity.issuance.evidence.Openid4VciCredentialOfferPreauthorizedCode +import com.android.identity.issuance.evidence.Openid4VciTxCode import com.android.identity.util.Logger import com.android.identity_credential.wallet.ProvisioningViewModel import io.ktor.client.HttpClient @@ -7,6 +11,9 @@ import io.ktor.client.request.get import io.ktor.client.statement.readBytes import io.ktor.http.HttpStatusCode import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -14,13 +21,13 @@ import java.net.URLDecoder /** * Parse the Url Query component of an OID4VCI credential offer Url (from a deep link or Qr scan) - * and return a [ProvisioningViewModel.Openid4VciCredentialOffer] containing the + * and return a [Openid4VciCredentialOffer] containing the * Credential Issuer Uri and Credential (Config) Id that are used for initiating the * OpenId4VCI Credential Offer Issuance flow using [ProvisioningViewModel.start] call. */ suspend fun extractCredentialIssuerData( urlQueryComponent: String -): ProvisioningViewModel.Openid4VciCredentialOffer? { +): Openid4VciCredentialOffer? { try { val params = urlQueryComponent.split('&').map { val index = it.indexOf('=') @@ -45,25 +52,60 @@ suspend fun extractCredentialIssuerData( credentialOfferString = String(response.readBytes()) } val json = Json.parseToJsonElement(credentialOfferString).jsonObject - // extract Credential Issuer Uri and Credential (Config) Id - val credentialIssuerUri = json["credential_issuer"]!!.jsonPrimitive.content - val credentialConfigurationIds = json["credential_configuration_ids"]!!.jsonArray - // Right now only use the first configuration id - val credentialConfigurationId = credentialConfigurationIds[0].jsonPrimitive.content - var preauthorizedCode: String? = null - if (json.containsKey("grants")) { - val codeObject = json["grants"]!!.jsonObject["urn:ietf:params:oauth:grant-type:pre-authorized_code"] - if (codeObject != null) { - preauthorizedCode = codeObject.jsonObject["pre-authorized_code"]?.jsonPrimitive?.content - } - } - return ProvisioningViewModel.Openid4VciCredentialOffer( - credentialIssuerUri, - credentialConfigurationId, - preauthorizedCode - ) + return Openid4VciCredentialOffer.parse(json) } catch (err: Exception) { Logger.e("CredentialOffer", "Parsing error", err) return null } -} \ No newline at end of file +} + +fun Openid4VciCredentialOffer.Companion.parse(json: JsonObject): Openid4VciCredentialOffer { + val credentialIssuerUri = json["credential_issuer"]!!.jsonPrimitive.content + val credentialConfigurationIds = json["credential_configuration_ids"]!!.jsonArray + // Right now only use the first configuration id + val credentialConfigurationId = credentialConfigurationIds[0].jsonPrimitive.content + if (json.containsKey("grants")) { + val grants = json["grants"]!!.jsonObject + val preAuthGrant = grants["urn:ietf:params:oauth:grant-type:pre-authorized_code"] + if (preAuthGrant != null) { + val grant = preAuthGrant.jsonObject + val preauthorizedCode = grant["pre-authorized_code"]!!.jsonPrimitive.content + val authorizationServer = grant["authorization_server"]?.jsonPrimitive?.content + val txCode = extractTxCode(grant["tx_code"]) + return Openid4VciCredentialOfferPreauthorizedCode( + issuerUri = credentialIssuerUri, + configurationId = credentialConfigurationId, + authorizationServer = authorizationServer, + preauthorizedCode = preauthorizedCode, + txCode = txCode + ) + } else { + val grant = grants["authorization_code"]?.jsonObject + if (grant != null) { + val issuerState = grant["issuer_state"]?.jsonPrimitive?.content + val authorizationServer = grant["authorization_server"]?.jsonPrimitive?.content + return Openid4VciCredentialOfferAuthorizationCode( + issuerUri = credentialIssuerUri, + configurationId = credentialConfigurationId, + authorizationServer = authorizationServer, + issuerState = issuerState + ) + } + } + } + throw IllegalArgumentException("Could not parse credential offer") +} + +private fun extractTxCode(txCodeJson: JsonElement?): Openid4VciTxCode? { + return if (txCodeJson == null) { + null + } else { + val obj = txCodeJson.jsonObject + Openid4VciTxCode( + description = obj["description"]?.jsonPrimitive?.content ?: + "Enter transaction code that was previously communicated to you", + length = obj["length"]?.jsonPrimitive?.intOrNull ?: Int.MAX_VALUE, + isNumeric = obj["input_mode"]?.jsonPrimitive?.content != "text" + ) + } +} diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt index be1e3d006..44e68856d 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt @@ -28,7 +28,6 @@ import com.android.identity.appsupport.ui.consent.MdocConsentField import com.android.identity.credential.Credential import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto -import com.android.identity.document.DocumentStore import com.android.identity.issuance.DocumentExtensions.documentConfiguration import com.android.identity.issuance.IssuingAuthorityException import com.android.identity.issuance.evidence.EvidenceRequestCompletionMessage @@ -39,7 +38,7 @@ import com.android.identity.issuance.evidence.EvidenceRequestIcaoPassiveAuthenti import com.android.identity.issuance.evidence.EvidenceRequestMessage import com.android.identity.issuance.evidence.EvidenceRequestNotificationPermission import com.android.identity.issuance.evidence.EvidenceRequestOpenid4Vp -import com.android.identity.issuance.evidence.EvidenceRequestPreauthorizedCode +import com.android.identity.issuance.evidence.EvidenceRequestCredentialOffer import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice import com.android.identity.issuance.evidence.EvidenceRequestQuestionString import com.android.identity.issuance.evidence.EvidenceRequestSelfieVideo @@ -264,7 +263,7 @@ fun ProvisionDocumentScreen( ) } - is EvidenceRequestPreauthorizedCode -> { + is EvidenceRequestCredentialOffer -> { // should have been processed by the model internally Logger.e(TAG, "Unexpected evidence request type: EvidenceRequestPreauthorizedCode") Row(