Skip to content

Commit

Permalink
SIOP: Request specific claims in ISO use case
Browse files Browse the repository at this point in the history
  • Loading branch information
nodh committed Jul 31, 2023
1 parent 36c6a75 commit 363cd7e
Show file tree
Hide file tree
Showing 8 changed files with 77 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import kotlin.time.toDuration
* Implements [OIDC for VP](https://openid.net/specs/openid-connect-4-verifiable-presentations-1_0.html) (2023-04-21)
* as well as [SIOP V2](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html) (2023-01-01).
*
* The [verifier] creates the Authentication Request, see [OidcSiopWallet] for the holder.
* This class creates the Authentication Request, [verifier] verifies the response. See [OidcSiopWallet] for the holder.
*/
class OidcSiopVerifier(
private val verifier: Verifier,
Expand All @@ -60,6 +60,7 @@ class OidcSiopVerifier(
timeLeewaySeconds: Long = 300L,
private val clock: Clock = Clock.System,
private val credentialScheme: ConstantIndex.CredentialScheme? = null,
private val requestedAttributes: List<String>? = null,
) {

private val timeLeeway = timeLeewaySeconds.toDuration(DurationUnit.SECONDS)
Expand All @@ -75,6 +76,7 @@ class OidcSiopVerifier(
timeLeewaySeconds: Long = 300L,
clock: Clock = Clock.System,
credentialScheme: ConstantIndex.CredentialScheme? = null,
requestedAttributes: List<String>? = null,
) = OidcSiopVerifier(
verifier = verifier,
relyingPartyUrl = relyingPartyUrl,
Expand All @@ -84,6 +86,7 @@ class OidcSiopVerifier(
timeLeewaySeconds = timeLeewaySeconds,
clock = clock,
credentialScheme = credentialScheme,
requestedAttributes = requestedAttributes,
)
}

Expand All @@ -95,13 +98,11 @@ class OidcSiopVerifier(
fun createAuthnRequestUrl(
walletUrl: String,
responseMode: String? = null,
credentialScheme: ConstantIndex.CredentialScheme? = null,
state: String? = uuid4().toString(),
): String {
val urlBuilder = URLBuilder(walletUrl)
createAuthnRequest(
responseMode = responseMode,
credentialScheme = credentialScheme,
state = state,
).encodeToParameters()
.forEach { urlBuilder.parameters.append(it.key, it.value) }
Expand All @@ -112,20 +113,17 @@ class OidcSiopVerifier(
* Creates an OIDC Authentication Request, encoded as query parameters to the [walletUrl],
* containing a JWS Authorization Request (JAR, RFC9101), containing the request parameters itself.
*
* @param credentialScheme which credential to request, or any credential if `null`
* @param responseMode which response mode to request, see [OpenIdConstants.ResponseModes]
* @param state opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult]
*/
suspend fun createAuthnRequestUrlWithRequestObject(
walletUrl: String,
responseMode: String? = null,
credentialScheme: ConstantIndex.CredentialScheme? = null,
state: String? = uuid4().toString(),
): String {
val urlBuilder = URLBuilder(walletUrl)
createAuthnRequestAsRequestObject(
responseMode = responseMode,
credentialScheme = credentialScheme,
state = state,
).encodeToParameters()
.forEach { urlBuilder.parameters.append(it.key, it.value) }
Expand All @@ -135,18 +133,15 @@ class OidcSiopVerifier(
/**
* Creates an JWS Authorization Request (JAR, RFC9101), wrapping the usual [AuthenticationRequestParameters].
*
* @param credentialScheme which credential to request, or any credential if `null`
* @param responseMode which response mode to request, see [OpenIdConstants.ResponseModes]
* @param state opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult]
*/
suspend fun createAuthnRequestAsRequestObject(
responseMode: String? = null,
credentialScheme: ConstantIndex.CredentialScheme? = null,
state: String? = uuid4().toString(),
): AuthenticationRequestParameters {
val requestObject = createAuthnRequest(
responseMode = responseMode,
credentialScheme = credentialScheme,
state = state,
)
val requestObjectSerialized = jsonSerializer.encodeToString(
Expand All @@ -166,12 +161,10 @@ class OidcSiopVerifier(
*
* Callers may serialize the result with `result.encodeToParameters().formUrlEncode()`
*
* @param credentialScheme which credential to request, or any credential if `null`
* @param responseMode which response mode to request, see [OpenIdConstants.ResponseModes]
* @param state opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult]
*/
fun createAuthnRequest(
credentialScheme: ConstantIndex.CredentialScheme? = null,
responseMode: String? = null,
state: String? = uuid4().toString(),
): AuthenticationRequestParameters {
Expand Down Expand Up @@ -230,17 +223,26 @@ class OidcSiopVerifier(
id = uuid4().toString(),
schema = arrayOf(SchemaReference(credentialScheme?.schemaUri ?: "https://example.com")),
constraints = Constraint(
fields = arrayOf(
mainConstraint
// TODO Add constraints for requested MDOC fields
),
fields = (requestedAttributes?.let { createConstraints(it) } ?: arrayOf()) + mainConstraint,
),
)
),
),
)
}

private fun createConstraints(attributeTypes: List<String>): Array<ConstraintField> {
if (credentialScheme?.credentialFormat != ConstantIndex.CredentialFormat.ISO_18013)
return arrayOf()

return attributeTypes.map {
ConstraintField(
path = arrayOf("\$.mdoc.$it"),
intentToRetain = false,
)
}.toTypedArray()
}

sealed class AuthnResponseResult {
/**
* Error in parsing the URL or content itself, before verifying the contents of the OpenId response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,14 @@ class OidcSiopWallet(
val requestedScopes = params.scope?.split(" ")
?.filterNot { it == SCOPE_OPENID }?.filterNot { it == SCOPE_PROFILE }
?.toList() ?: listOf()
// TODO also add requested claims
val vp = holder.createPresentation(params.nonce, audience, requestedScopes)
val requestedClaims = params.presentationDefinition?.inputDescriptors
?.mapNotNull { it.constraints }?.flatMap { it.fields?.toList() ?: listOf() }
?.flatMap { it.path.toList() }
?.filter { it != "$.type" }
?.filter { it != "$.mdoc.doctype" }
?.map { it.removePrefix("\$.mdoc.") }
?: listOf()
val vp = holder.createPresentation(params.nonce, audience, requestedScopes, requestedClaims)
?: return KmmResult.failure(OAuth2Exception(Errors.USER_CANCELLED))
.also { Napier.w("Could not create presentation") }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import at.asitplus.wallet.lib.agent.IssuerAgent
import at.asitplus.wallet.lib.agent.Verifier
import at.asitplus.wallet.lib.agent.VerifierAgent
import at.asitplus.wallet.lib.data.ConstantIndex
import at.asitplus.wallet.lib.iso.IsoDataModelConstants
import com.benasher44.uuid.uuid4
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldBeSingleton
import io.kotest.matchers.collections.shouldHaveSingleElement
import io.kotest.matchers.collections.shouldMatchEach
import io.kotest.matchers.collections.shouldNotBeEmpty
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.coroutines.runBlocking
Expand Down Expand Up @@ -57,6 +61,7 @@ class OidcSiopIsoProtocolTest : FreeSpec({
verifier = verifierAgent,
cryptoService = verifierCryptoService,
relyingPartyUrl = relyingPartyUrl,
credentialScheme = ConstantIndex.MobileDrivingLicence2023,
)
}

Expand All @@ -76,4 +81,29 @@ class OidcSiopIsoProtocolTest : FreeSpec({
document.invalidItems.shouldBeEmpty()
}

"Selective Disclosure" {
verifierSiop = OidcSiopVerifier.newInstance(
verifier = verifierAgent,
cryptoService = verifierCryptoService,
relyingPartyUrl = relyingPartyUrl,
credentialScheme = ConstantIndex.MobileDrivingLicence2023,
requestedAttributes = listOf(IsoDataModelConstants.DataElements.FAMILY_NAME),
)
val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl)
println(authnRequest)

val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow()
authnResponse.shouldBeInstanceOf<OidcSiopWallet.AuthenticationResponseResult.Redirect>()
println(authnResponse)

val result = verifierSiop.validateAuthnResponse(authnResponse.url)
result.shouldBeInstanceOf<OidcSiopVerifier.AuthnResponseResult.SuccessIso>()
val document = result.document
println(document)
document.validItems.shouldNotBeEmpty()
document.validItems.shouldBeSingleton()
document.validItems.shouldHaveSingleElement { it.elementIdentifier == IsoDataModelConstants.DataElements.FAMILY_NAME }
document.invalidItems.shouldBeEmpty()
}

})
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ class OidcSiopProtocolTest : FreeSpec({
}

"test specific credential" {
verifierSiop = OidcSiopVerifier.newInstance(
verifier = verifierAgent,
cryptoService = verifierCryptoService,
relyingPartyUrl = relyingPartyUrl,
credentialScheme = TestCredentialScheme
)
holderAgent.storeCredentials(
IssuerAgent.newDefaultInstance(
DefaultCryptoService(),
Expand All @@ -179,7 +185,7 @@ class OidcSiopProtocolTest : FreeSpec({
).toStoreCredentialInput()
)

val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl, credentialScheme = TestCredentialScheme)
val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl)
println(authnRequest)

val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,15 @@ interface Holder {

/**
* Creates a [VerifiablePresentation] serialized as a JWT for all the credentials we have stored,
* that match the [attributeTypes] (if specified).
* that match the [attributeTypes] (if specified). Optionally filters by [requestedClaims] (e.g. in ISO case).
*
* May return null if no valid credentials (i.e. non-revoked, matching attribute name) are available.
*/
suspend fun createPresentation(
challenge: String,
audienceId: String,
attributeTypes: Collection<String>? = null,
requestedClaims: Collection<String>? = null,
): CreatePresentationResult?

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,15 @@ class HolderAgent(

/**
* Creates a [VerifiablePresentation] serialized as a JWT for all the credentials we have stored,
* that match [attributeTypes] (if specified).
* that match [attributeTypes] (if specified). Optionally filters by [requestedClaims] (e.g. in ISO case).
*
* May return null if no valid credentials (i.e. non-revoked, matching attribute name) are available.
*/
override suspend fun createPresentation(
challenge: String,
audienceId: String,
attributeTypes: Collection<String>?,
requestedClaims: Collection<String>?,
): Holder.CreatePresentationResult? {
val credentials = subjectCredentialStore.getCredentials(attributeTypes).getOrNull()
?: return null
Expand Down Expand Up @@ -196,7 +197,7 @@ class HolderAgent(
docType = DOC_TYPE_MDL,
issuerSigned = IssuerSigned(
namespaces = mapOf(NAMESPACE_MDL to IssuerSignedList(attributes.entries.filter {
it.discloseItem(attributeTypes)
it.discloseItem(requestedClaims)
})),
issuerAuth = validIsoCredential.issuerAuth
),
Expand All @@ -213,9 +214,9 @@ class HolderAgent(
return null
}

private fun ByteStringWrapper<IssuerSignedItem>.discloseItem(attributeTypes: Collection<String>?) =
if (attributeTypes?.isNotEmpty() == true) {
value.elementIdentifier in attributeTypes
private fun ByteStringWrapper<IssuerSignedItem>.discloseItem(requestedClaims: Collection<String>?) =
if (requestedClaims?.isNotEmpty() == true) {
value.elementIdentifier in requestedClaims
} else {
true
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package at.asitplus.wallet.lib.agent

import at.asitplus.KmmResult
import at.asitplus.wallet.lib.data.ConstantIndex
import at.asitplus.wallet.lib.data.VerifiableCredentialJws
import at.asitplus.wallet.lib.iso.IssuerSigned

Expand Down Expand Up @@ -31,9 +32,7 @@ class InMemorySubjectCredentialStore : SubjectCredentialStore {
requiredAttributeTypes: Collection<String>?
) = if (requiredAttributeTypes?.isNotEmpty() == true) {
when (this) {
is SubjectCredentialStore.StoreEntry.Iso -> issuerSigned.namespaces?.keys?.any { it in requiredAttributeTypes }
?: false

is SubjectCredentialStore.StoreEntry.Iso -> ConstantIndex.MobileDrivingLicence2023.vcType in requiredAttributeTypes
is SubjectCredentialStore.StoreEntry.Vc -> vc.vc.type.any { it in requiredAttributeTypes }
}
} else true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ data class ConstraintField(
// should be JSONPath
val path: Array<String>,
@SerialName("filter")
val filter: ConstraintFilter? = null
val filter: ConstraintFilter? = null,
@SerialName("intent_to_retain")
val intentToRetain: Boolean? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
Expand All @@ -32,8 +34,7 @@ data class ConstraintField(
if (predicate != other.predicate) return false
if (!path.contentEquals(other.path)) return false
if (filter != other.filter) return false

return true
return intentToRetain == other.intentToRetain
}

override fun hashCode(): Int {
Expand All @@ -42,6 +43,7 @@ data class ConstraintField(
result = 31 * result + (predicate?.hashCode() ?: 0)
result = 31 * result + path.contentHashCode()
result = 31 * result + (filter?.hashCode() ?: 0)
result = 31 * result + (intentToRetain?.hashCode() ?: 0)
return result
}
}

0 comments on commit 363cd7e

Please sign in to comment.