diff --git a/vclib-openid/build.gradle.kts b/vclib-openid/build.gradle.kts index 00bf26006..e6f60bf4b 100644 --- a/vclib-openid/build.gradle.kts +++ b/vclib-openid/build.gradle.kts @@ -22,20 +22,27 @@ kotlin { iosX64() sourceSets { - commonMain { + commonMain { dependencies { api(project(":vclib")) commonImplementationDependencies() + implementation(ktor("client-core")) } } - jvmMain { + commonTest { + dependencies { + implementation(ktor("client-mock")) + } + } + + jvmMain { dependencies { implementation(bouncycastle("bcprov")) } } - jvmTest { + jvmTest { dependencies { implementation("com.nimbusds:nimbus-jose-jwt:${VcLibVersions.Jvm.`jose-jwt`}") implementation("org.json:json:${VcLibVersions.Jvm.json}") diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt index 17b97f857..7e7a3bfd6 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/AuthenticationRequestParameters.kt @@ -26,13 +26,13 @@ data class AuthenticationRequestParameters( * * Optional when JAR (RFC9101) is used. */ - @SerialName("response_type") + @SerialName(AuthenticationRequestConstants.SerialNames.responseType) val responseType: String? = null, /** * OIDC: REQUIRED. OAuth 2.0 Client Identifier valid at the Authorization Server. */ - @SerialName("client_id") + @SerialName(AuthenticationRequestConstants.SerialNames.clientId) val clientId: String, /** @@ -42,7 +42,7 @@ data class AuthenticationRequestParameters( * * Optional when JAR (RFC9101) is used. */ - @SerialName("redirect_uri") + @SerialName(AuthenticationRequestConstants.SerialNames.redirectUrl) val redirectUrl: String? = null, /** @@ -51,7 +51,7 @@ data class AuthenticationRequestParameters( * understood by an implementation SHOULD be ignored. * e.g. `profile` or `com.example.healthCardCredential` */ - @SerialName("scope") + @SerialName(AuthenticationRequestConstants.SerialNames.scope) val scope: String? = null, /** @@ -59,7 +59,7 @@ data class AuthenticationRequestParameters( * Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the value of this * parameter with a browser cookie. */ - @SerialName("state") + @SerialName(AuthenticationRequestConstants.SerialNames.state) val state: String? = null, /** @@ -67,14 +67,14 @@ data class AuthenticationRequestParameters( * The value is passed through unmodified from the Authentication Request to the ID Token. Sufficient entropy MUST * be present in the nonce values used to prevent attackers from guessing values. */ - @SerialName("nonce") + @SerialName(AuthenticationRequestConstants.SerialNames.nonce) val nonce: String? = null, /** * OIDC: OPTIONAL. This parameter is used to request that specific Claims be returned. The value is a JSON object * listing the requested Claims. */ - @SerialName("claims") + @SerialName(AuthenticationRequestConstants.SerialNames.claims) val claims: AuthnRequestClaims? = null, /** @@ -82,7 +82,7 @@ data class AuthenticationRequestParameters( * that would normally be provided to an OP during Dynamic RP Registration. * It MUST not be present if the RP uses OpenID Federation 1.0 Automatic Registration to pass its metadata. */ - @SerialName("client_metadata") + @SerialName(AuthenticationRequestConstants.SerialNames.clientMetadata) val clientMetadata: RelyingPartyMetadata? = null, /** @@ -90,7 +90,7 @@ data class AuthenticationRequestParameters( * that would normally be provided to an OP during Dynamic RP Registration. * It MUST not be present if the RP uses OpenID Federation 1.0 Automatic Registration to pass its metadata. */ - @SerialName("client_metadata_uri") + @SerialName(AuthenticationRequestConstants.SerialNames.clientMetadataUri) val clientMetadataUri: String? = null, /** @@ -99,7 +99,7 @@ data class AuthenticationRequestParameters( * logged in or is logged in by the request, then the Authorization Server returns a positive response; otherwise, * it SHOULD return an error, such as login_required. */ - @SerialName("id_token_hint") + @SerialName(AuthenticationRequestConstants.SerialNames.idTokenHint) val idTokenHint: String? = null, /** @@ -107,7 +107,7 @@ data class AuthenticationRequestParameters( * parameters stated in Section 4 of RFC6749 (OAuth 2.0). If this parameter is present in the authorization request, * `request_uri` MUST NOT be present. */ - @SerialName("request") + @SerialName(AuthenticationRequestConstants.SerialNames.request) val request: String? = null, /** @@ -115,7 +115,7 @@ data class AuthenticationRequestParameters( * Request Object URI referencing the authorization request parameters stated in Section 4 of RFC6749 (OAuth 2.0). * If this parameter is present in the authorization request, `request` MUST NOT be present. */ - @SerialName("request_uri") + @SerialName(AuthenticationRequestConstants.SerialNames.requestUri) val requestUri: String? = null, /** @@ -128,7 +128,7 @@ data class AuthenticationRequestParameters( * * See [IdTokenType] for valid values. */ - @SerialName("id_token_type") + @SerialName(AuthenticationRequestConstants.SerialNames.idTokenType) val idTokenType: String? = null, /** @@ -136,7 +136,7 @@ data class AuthenticationRequestParameters( * `presentation_definition_uri` parameter, or a `scope` value representing a Presentation Definition is not * present. */ - @SerialName("presentation_definition") + @SerialName(AuthenticationRequestConstants.SerialNames.presentationDefinition) val presentationDefinition: PresentationDefinition? = null, /** @@ -144,7 +144,7 @@ data class AuthenticationRequestParameters( * be retrieved. This parameter MUST be present when `presentation_definition` parameter, or a `scope` value * representing a Presentation Definition is not present. */ - @SerialName("authorization_details") + @SerialName(AuthenticationRequestConstants.SerialNames.authorizationDetails) val authorizationDetails: AuthorizationDetails? = null, /** @@ -157,7 +157,7 @@ data class AuthenticationRequestParameters( * Identifier schemes the Wallet supports prior to sending the Authorization Request in order to choose a supported * scheme. */ - @SerialName("client_id_scheme") + @SerialName(AuthenticationRequestConstants.SerialNames.clientIdScheme) val clientIdScheme: String? = null, /** @@ -165,14 +165,14 @@ data class AuthenticationRequestParameters( * defined in SIOPv2 to determine the Wallet's capabilities and endpoints, using the `wallet_issuer` value as the * Issuer Identifier referred to in SIOPv2. This is RECOMMENDED in Dynamic Credential Requests. */ - @SerialName("wallet_issuer") + @SerialName(AuthenticationRequestConstants.SerialNames.walletIssuer) val walletIssuer: String? = null, /** * OID4VP: OPTIONAL. String containing an opaque End-User hint that the Wallet MAY use in subsequent callbacks to * optimize the End-User's experience. This is RECOMMENDED in Dynamic Credential Requests. */ - @SerialName("user_hint") + @SerialName(AuthenticationRequestConstants.SerialNames.userHint) val userHint: String? = null, /** @@ -180,7 +180,7 @@ data class AuthenticationRequestParameters( * this parameter is typically passed in a Credential Offer from the Credential Issuer to the Wallet. This request * parameter is used to pass the issuer_state value back to the Credential Issuer. */ - @SerialName("issuer_state") + @SerialName(AuthenticationRequestConstants.SerialNames.issuerState) val issuerState: String? = null, /** @@ -191,7 +191,7 @@ data class AuthenticationRequestParameters( * OIDC SIOPv2: This response mode `post` is used to request the Self-Issued OP to deliver the result of the * authentication process to a certain endpoint using the HTTP POST method. */ - @SerialName("response_mode") + @SerialName(AuthenticationRequestConstants.SerialNames.responseMode) val responseMode: String? = null, /** @@ -202,7 +202,7 @@ data class AuthenticationRequestParameters( * Request parameter is present when the Response Mode is `direct_post`, the Wallet MUST return an * `invalid_request` Authorization Response error. */ - @SerialName("response_uri") + @SerialName(AuthenticationRequestConstants.SerialNames.responseUrl) val responseUrl: String? = null, /** @@ -210,7 +210,7 @@ data class AuthenticationRequestParameters( * (audience) as members with their semantics being the same as defined in the JWT (RFC7519) specification. The * value of `aud` should be the value of the authorization server (AS) `issuer`, as defined in RFC 8414. */ - @SerialName("aud") + @SerialName(AuthenticationRequestConstants.SerialNames.audience) val audience: String? = null, /** @@ -218,13 +218,13 @@ data class AuthenticationRequestParameters( * (audience) as members with their semantics being the same as defined in the JWT (RFC7519) specification. The * value of `aud` should be the value of the authorization server (AS) `issuer`, as defined in RFC 8414. */ - @SerialName("iss") + @SerialName(AuthenticationRequestConstants.SerialNames.issuer) val issuer: String? = null, /** * OPTIONAL. Time at which the request was issued. */ - @SerialName("iat") + @SerialName(AuthenticationRequestConstants.SerialNames.issuedAt) @Serializable(with = InstantLongSerializer::class) val issuedAt: Instant? = null, ) { @@ -240,3 +240,35 @@ data class AuthenticationRequestParameters( } } } + +// restricted to internal as there is no reason to make this public for now +object AuthenticationRequestConstants { + object SerialNames { + + const val audience = "aud" + const val authorizationDetails = "authorization_details" + const val claims = "claims" + const val clientId = "client_id" + const val clientIdScheme = "client_id_scheme" + const val clientMetadata = "client_metadata" + const val clientMetadataUri = "client_metadata_uri" + const val idTokenHint = "id_token_hint" + const val idTokenType = "id_token_type" + const val issuedAt = "iat" + const val issuer = "iss" + const val issuerState = "issuer_state" + const val nonce = "nonce" + const val presentationDefinition = "presentation_definition" + const val presentationDefinitionUri = "presentation_definition_uri" + const val redirectUrl = "redirect_uri" + const val request = "request" + const val requestUri = "request_uri" + const val responseMode = "response_mode" + const val responseType = "response_type" + const val responseUrl = "response_uri" + const val scope = "scope" + const val state = "state" + const val userHint = "user_hint" + const val walletIssuer = "wallet_issuer" + } +} \ No newline at end of file diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt index d18c4e092..f774c91eb 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt @@ -32,8 +32,13 @@ import at.asitplus.wallet.lib.oidvci.encodeToParameters import at.asitplus.wallet.lib.oidvci.formUrlEncode import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier -import io.ktor.http.* -import io.ktor.util.* +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.util.flattenEntries import io.matthewnelson.encoding.base16.Base16 import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock @@ -61,12 +66,11 @@ class OidcSiopWallet( */ private val jwkSetRetriever: (String) -> JsonWebKeySet? = { null }, /** - * Need to implement if `request_uri` parameters are used, i.e. the actual authn request can be retrieved - * from that URL. Implementations need to fetch the URL and return the content. + * Need to implement if the request parameters need to be fetched, i.e. the actual authn request can + * be retrieved from that URL. Implementations need to fetch the url and return request object candidates that have been retrieved. */ - private val requestRetriever: (String) -> String? = { null }, + private val requestObjectCandidateRetriever: RequestObjectCandidateRetriever = { listOf() }, ) { - companion object { fun newInstance( holder: Holder, @@ -74,9 +78,9 @@ class OidcSiopWallet( jwsService: JwsService = DefaultJwsService(cryptoService), verifierJwsService: VerifierJwsService = DefaultVerifierJwsService(), clock: Clock = Clock.System, + httpClient: HttpClient, clientId: String = "https://wallet.a-sit.at/", jwkSetRetriever: (String) -> JsonWebKeySet? = { null }, - requestRetriever: (String) -> String? = { null }, ) = OidcSiopWallet( holder = holder, agentPublicKey = cryptoService.publicKey, @@ -85,7 +89,29 @@ class OidcSiopWallet( clock = clock, clientId = clientId, jwkSetRetriever = jwkSetRetriever, - requestRetriever = requestRetriever, + requestObjectCandidateRetriever = httpClient.asRequestObjectCandidateRetriever, + ) + + // mark this as internal so it can be used for testing purposes + // the request object candidate retriever should usually be derived from a http client as in the other constructor + internal fun newInstance( + holder: Holder, + cryptoService: CryptoService, + jwsService: JwsService = DefaultJwsService(cryptoService), + verifierJwsService: VerifierJwsService = DefaultVerifierJwsService(), + clock: Clock = Clock.System, + clientId: String = "https://wallet.a-sit.at/", + jwkSetRetriever: (String) -> JsonWebKeySet? = { null }, + requestObjectCandidateRetriever: RequestObjectCandidateRetriever = { listOf() }, + ) = OidcSiopWallet( + holder = holder, + agentPublicKey = cryptoService.publicKey, + jwsService = jwsService, + verifierJwsService = verifierJwsService, + clock = clock, + clientId = clientId, + jwkSetRetriever = jwkSetRetriever, + requestObjectCandidateRetriever = requestObjectCandidateRetriever, ) } @@ -123,29 +149,64 @@ class OidcSiopWallet( /** * Pass in the URL sent by the Verifier (containing the [AuthenticationRequestParameters] as query parameters), - * to create [AuthenticationResponseParameters] that can be sent back to the Verifier, see + * to create [AuthenticationResponseResult] that can be sent back to the Verifier, see * [AuthenticationResponseResult]. */ suspend fun createAuthnResponse(input: String): KmmResult { - val params = kotlin.runCatching { - Url(input).parameters.flattenEntries().toMap().decodeFromUrlQuery() - }.getOrElse { + val params = retrieveAuthenticationRequestParameters(input) + return createAuthnResponse(params) + } + + /** + * Pass in the URL sent by the Verifier (containing the [AuthenticationRequestParameters] as query parameters), + * to create [AuthenticationResponseParameters] that can be sent back to the Verifier, see + * [AuthenticationResponseResult]. + */ + suspend fun retrieveAuthenticationRequestParameters(input: String): AuthenticationRequestParameters { + val params = kotlin.run { + // maybe it's already a request jws? parseRequestObjectJws(input) - } ?: return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) - .also { Napier.w("Could not parse authentication request") } - return extractRequestObject(params)?.let { createAuthnResponse(it) } - ?: createAuthnResponse(params) + } ?: kotlin.runCatching { + // maybe it's a url that already encodes the authentication request as url parameters + Url(input).parameters.flattenEntries().toMap() + .decodeFromUrlQuery() + }.getOrNull() ?: kotlin.runCatching { + // maybe it's a url that yields the request object in some other way + val url = Url(input) + val candidates = requestObjectCandidateRetriever.invoke(url) + var result: AuthenticationRequestParameters? = null + for (candidate in candidates) { + result = kotlin.runCatching { + retrieveAuthenticationRequestParameters(candidate) + }.getOrDefault(result) + if (result != null) break + } + result + }.getOrNull() ?: throw OAuth2Exception(Errors.INVALID_REQUEST) + .also { Napier.w("Could not parse authentication request: $input") } + + val requestParams = params.requestUri?.let { + // go down the rabbit hole following the request_uri parameters + retrieveAuthenticationRequestParameters(it).also { newParams -> + if (params.clientId != newParams.clientId) { + throw OAuth2Exception(Errors.INVALID_REQUEST) + .also { Napier.e("Client ids do not match: before: $params, after: $newParams") } + } + } + } ?: params + + val authenticationRequestParameters = requestParams.let { extractRequestObject(it) ?: it } + if (authenticationRequestParameters.clientId != requestParams.clientId) { + throw OAuth2Exception(Errors.INVALID_REQUEST) + .also { Napier.w("Client ids do not match: outer: $requestParams, inner: $authenticationRequestParameters") } + } + return authenticationRequestParameters } private fun extractRequestObject(params: AuthenticationRequestParameters): AuthenticationRequestParameters? { params.request?.let { requestObject -> return parseRequestObjectJws(requestObject) } - params.requestUri?.let { uri -> - requestRetriever.invoke(uri)?.let { requestObject -> - return parseRequestObjectJws(requestObject) - } - } return null } @@ -167,7 +228,7 @@ class OidcSiopWallet( suspend fun createAuthnResponse( request: AuthenticationRequestParameters ): KmmResult = createAuthnResponseParams(request).fold( - { responseParams -> + onSuccess = { responseParams -> if (request.responseType == null) { return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) } @@ -178,12 +239,22 @@ class OidcSiopWallet( if (request.redirectUrl == null) return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) val body = responseParams.encodeToParameters().formUrlEncode() - return KmmResult.success(AuthenticationResponseResult.Post(request.redirectUrl, body)) + return KmmResult.success( + AuthenticationResponseResult.Post( + request.redirectUrl, + body + ) + ) } else if (request.responseMode?.startsWith(DIRECT_POST) == true) { if (request.responseUrl == null || request.redirectUrl != null) return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) val body = responseParams.encodeToParameters().formUrlEncode() - return KmmResult.success(AuthenticationResponseResult.Post(request.responseUrl, body)) + return KmmResult.success( + AuthenticationResponseResult.Post( + request.responseUrl, + body + ) + ) } else if (request.responseMode?.startsWith(QUERY) == true) { if (request.redirectUrl == null) return KmmResult.failure(OAuth2Exception(Errors.INVALID_REQUEST)) @@ -204,7 +275,8 @@ class OidcSiopWallet( .buildString() return KmmResult.success(AuthenticationResponseResult.Redirect(url)) } - }, { + }, + onFailure = { return KmmResult.failure(it) } ) @@ -285,11 +357,17 @@ class OidcSiopWallet( val requestedSchemes = mutableListOf() if (requestedNamespace != null) { requestedSchemes.add(AttributeIndex.resolveIsoNamespace(requestedNamespace) - ?: return KmmResult.failure(OAuth2Exception(Errors.USER_CANCELLED)) + ?: return KmmResult.failure( + OAuth2Exception( + Errors.USER_CANCELLED + ) + ) .also { Napier.w("Could not resolve requested namespace $requestedNamespace") }) requestedAttributeTypes.forEach { requestedAttributeTyp -> requestedSchemes.add(AttributeIndex.resolveAttributeType(requestedAttributeTyp) - ?: return KmmResult.failure(OAuth2Exception(Errors.USER_CANCELLED)) + ?: return KmmResult.failure( + OAuth2Exception(Errors.USER_CANCELLED) + ) .also { Napier.w("Could not resolve requested attribute type $it") }) } } @@ -307,8 +385,9 @@ class OidcSiopWallet( audienceId = audience, credentialSchemes = requestedSchemes.toList().ifEmpty { null }, requestedClaims = requestedClaims.ifEmpty { null } - ) ?: return KmmResult.failure(OAuth2Exception(Errors.USER_CANCELLED)) - .also { Napier.w("Could not create presentation") } + ) + ?: return KmmResult.failure(OAuth2Exception(Errors.USER_CANCELLED)) + .also { Napier.w("Could not create presentation") } when (vp) { is Holder.CreatePresentationResult.Signed -> { @@ -386,3 +465,17 @@ class OidcSiopWallet( } } + +typealias RequestObjectCandidateRetriever = suspend (Url) -> List + +private val HttpClient.asRequestObjectCandidateRetriever: RequestObjectCandidateRetriever + get() = { + // currently supported in order of priority: + // 1. use redirect location as new starting point if available + // 2. use resonse body as new starting point + val response = this.get(it) + listOfNotNull( + response.headers[HttpHeaders.Location], + response.bodyAsText(), + ) + } \ No newline at end of file diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt index 0dddfc7c9..5691828fa 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt @@ -175,11 +175,13 @@ class OidcSiopInteropTest : FreeSpec({ ) JsonWebKeySet.deserialize(jwkset) else null }, - requestRetriever = { it -> - if (it == "https://verifier-backend.eudiw.dev/wallet/request.jwt/" + - "WLFJEn9AGbJfAcEyaQTzzxueqmeRazmsHIkxMRTkGRL1zyI7un-KJWaXtulrfiSS38LlU5ABDB9Zdsfq_11r8Q" + requestObjectCandidateRetriever = { it -> + listOfNotNull( + if (it.toString() == "https://verifier-backend.eudiw.dev/wallet/request.jwt/" + + "WLFJEn9AGbJfAcEyaQTzzxueqmeRazmsHIkxMRTkGRL1zyI7un-KJWaXtulrfiSS38LlU5ABDB9Zdsfq_11r8Q" + ) + requestObject else null ) - requestObject else null } ) diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt index ac4150cde..a76ab6eee 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt @@ -10,6 +10,7 @@ import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService import at.asitplus.wallet.lib.oidvci.decodeFromPostBody import at.asitplus.wallet.lib.oidvci.decodeFromUrlQuery @@ -21,10 +22,20 @@ import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain import io.kotest.matchers.string.shouldStartWith import io.kotest.matchers.types.shouldBeInstanceOf +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondBadRequest +import io.ktor.client.engine.mock.respondRedirect +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.runBlocking class OidcSiopProtocolTest : FreeSpec({ @@ -77,7 +88,8 @@ class OidcSiopProtocolTest : FreeSpec({ .also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() + .also { println(it) } authnResponse.url.shouldNotContain("?") authnResponse.url.shouldContain("#") @@ -123,7 +135,8 @@ class OidcSiopProtocolTest : FreeSpec({ ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() + .also { println(it) } authnResponse.url.shouldBe(relyingPartyUrl) val result = verifierSiop.validateAuthnResponseFromPost(authnResponse.content) @@ -140,7 +153,8 @@ class OidcSiopProtocolTest : FreeSpec({ ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() + .also { println(it) } authnResponse.url.shouldContain("?") authnResponse.url.shouldNotContain("#") @@ -154,13 +168,17 @@ class OidcSiopProtocolTest : FreeSpec({ "test with deserializing" { val authnRequest = verifierSiop.createAuthnRequest() - val authnRequestUrlParams = authnRequest.encodeToParameters().formUrlEncode().also { println(it) } + val authnRequestUrlParams = + authnRequest.encodeToParameters().formUrlEncode().also { println(it) } - val parsedAuthnRequest: AuthenticationRequestParameters = authnRequestUrlParams.decodeFromUrlQuery() + val parsedAuthnRequest: AuthenticationRequestParameters = + authnRequestUrlParams.decodeFromUrlQuery() val authnResponse = holderSiop.createAuthnResponseParams(parsedAuthnRequest).getOrThrow() - val authnResponseParams = authnResponse.encodeToParameters().formUrlEncode().also { println(it) } + val authnResponseParams = + authnResponse.encodeToParameters().formUrlEncode().also { println(it) } - val parsedAuthnResponse: AuthenticationResponseParameters = authnResponseParams.decodeFromPostBody() + val parsedAuthnResponse: AuthenticationResponseParameters = + authnResponseParams.decodeFromPostBody() val result = verifierSiop.validateAuthnResponse(parsedAuthnResponse) result.shouldBeInstanceOf() result.vp.verifiableCredentials.shouldNotBeEmpty() @@ -179,7 +197,181 @@ class OidcSiopProtocolTest : FreeSpec({ ).also { println(it) } val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() - authnResponse.shouldBeInstanceOf().also { println(it) } + authnResponse.shouldBeInstanceOf() + .also { println(it) } + + val result = verifierSiop.validateAuthnResponse(authnResponse.url) + result.shouldBeInstanceOf() + result.vp.verifiableCredentials.shouldNotBeEmpty() + result.vp.verifiableCredentials.forEach { + it.vc.credentialSubject.shouldBeInstanceOf() + } + } + + "test with request object from request uri redirect" { + verifierSiop = OidcSiopVerifier.newInstance( + verifier = verifierAgent, + cryptoService = verifierCryptoService, + relyingPartyUrl = relyingPartyUrl, + ) + + val authnRequest = verifierSiop.createAuthnRequestUrl( + walletUrl = walletUrl, + credentialScheme = ConstantIndex.AtomicAttribute2023 + ).also { println(it) } + + val clientId = + Url(authnRequest).parameters[AuthenticationRequestConstants.SerialNames.clientId].let { + it shouldNotBe null + it!! + } + + val requestUrl = "http://www.example.com/requestUrl" + val mockEngine = MockEngine { request -> + if (request.url.toString() == requestUrl) { + respondRedirect(authnRequest) + } else { + respondBadRequest() + } + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + + val authRequestUrlWithRequestUri = URLBuilder("http://www.example.com/original").apply { + parameters.append(AuthenticationRequestConstants.SerialNames.clientId, clientId) + parameters.append(AuthenticationRequestConstants.SerialNames.requestUri, requestUrl) + }.buildString() + + holderSiop = OidcSiopWallet.newInstance( + holder = holderAgent, + cryptoService = holderCryptoService, + httpClient = httpClient, + ) + + val authnResponse = + holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() + authnResponse.shouldBeInstanceOf() + .also { println(it) } + + val result = verifierSiop.validateAuthnResponse(authnResponse.url) + result.shouldBeInstanceOf() + result.vp.verifiableCredentials.shouldNotBeEmpty() + result.vp.verifiableCredentials.forEach { + it.vc.credentialSubject.shouldBeInstanceOf() + } + } + + "test with request object from request uri response body if it is a url" { + verifierSiop = OidcSiopVerifier.newInstance( + verifier = verifierAgent, + cryptoService = verifierCryptoService, + relyingPartyUrl = relyingPartyUrl, + ) + + val authnRequest = verifierSiop.createAuthnRequestUrl( + walletUrl = walletUrl, + credentialScheme = ConstantIndex.AtomicAttribute2023 + ).also { println(it) } + + val clientId = + Url(authnRequest).parameters[AuthenticationRequestConstants.SerialNames.clientId].let { + it shouldNotBe null + it!! + } + + val requestUrl = "http://www.example.com/request" + val mockEngine = MockEngine { request -> + if (request.url.toString() == requestUrl) { + respond( + content = ByteReadChannel(authnRequest), + status = HttpStatusCode.OK, + ) + } else { + respondBadRequest() + } + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + + val authRequestUrlWithRequestUri = URLBuilder("http://www.example.com/original").apply { + parameters.append(AuthenticationRequestConstants.SerialNames.clientId, clientId) + parameters.append(AuthenticationRequestConstants.SerialNames.requestUri, requestUrl) + }.buildString() + + holderSiop = OidcSiopWallet.newInstance( + holder = holderAgent, + cryptoService = holderCryptoService, + httpClient = httpClient, + ) + + val authnResponse = + holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() + authnResponse.shouldBeInstanceOf() + .also { println(it) } + + val result = verifierSiop.validateAuthnResponse(authnResponse.url) + result.shouldBeInstanceOf() + result.vp.verifiableCredentials.shouldNotBeEmpty() + result.vp.verifiableCredentials.forEach { + it.vc.credentialSubject.shouldBeInstanceOf() + } + } + + "test with request object from request uri response body if it is a jws" { + verifierSiop = OidcSiopVerifier.newInstance( + verifier = verifierAgent, + cryptoService = verifierCryptoService, + relyingPartyUrl = relyingPartyUrl, + ) + + val authnRequest = verifierSiop.createAuthnRequest( + credentialScheme = ConstantIndex.AtomicAttribute2023 + ).also { println(it) } + + val requestUrl = "http://www.example.com/request" + val mockEngine = MockEngine { request -> + if (request.url.toString() == requestUrl) { + val authnRequestObjectJws = + DefaultJwsService(verifierCryptoService).createSignedJwsAddingParams( + payload = authnRequest.serialize().encodeToByteArray(), + addKeyId = true + ).getOrNull().let { + it shouldNotBe null + it!! + } + + respond( + content = ByteReadChannel(authnRequestObjectJws.serialize()), + status = HttpStatusCode.OK, + ) + } else { + respondBadRequest() + } + } + val httpClient = HttpClient(mockEngine) { + followRedirects = false + } + + val authRequestUrlWithRequestUri = URLBuilder("http://www.example.com/original").apply { + parameters.append( + AuthenticationRequestConstants.SerialNames.clientId, + authnRequest.clientId + ) + parameters.append(AuthenticationRequestConstants.SerialNames.requestUri, requestUrl) + }.buildString() + + holderSiop = OidcSiopWallet.newInstance( + holder = holderAgent, + cryptoService = holderCryptoService, + httpClient = httpClient, + ) + + val authnResponse = + holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() + authnResponse.shouldBeInstanceOf() + .also { println(it) } val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf()