Skip to content

Commit

Permalink
fix: integrate oidc4vc CredentialOffer to IssuanceSession (#943)
Browse files Browse the repository at this point in the history
  • Loading branch information
patlo-iog committed Apr 29, 2024
1 parent 492954d commit 46c5966
Show file tree
Hide file tree
Showing 13 changed files with 161 additions and 158 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import sttp.model.StatusCode
import sttp.tapir.json.zio.jsonBody
import sttp.tapir.{oneOfVariantValueMatcher, *}
import sttp.tapir.EndpointOutput.OneOfVariant
import zio.json.JsonEncoder

object EndpointOutputs {
def statusCodeMatcher(
Expand Down
8 changes: 8 additions & 0 deletions examples/st-oidc4vc/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# How to run issuance flow

## Prerequisites

- Docker installed
- Python 3 with the following packages installed
- [requests](https://pypi.org/project/requests/)
- [pyjwt](https://pyjwt.readthedocs.io/en/stable/)
- [cryptography](https://cryptography.io/en/latest/)

### 1. Spin up the agent stack with pre-configured Keycloak

```bash
Expand Down
2 changes: 2 additions & 0 deletions examples/st-oidc4vc/bootstrap/01_init_realm.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ grant_type: password
client_id: admin-cli
username: {{ keycloak_admin_user }}
password: {{ keycloak_admin_password }}
[Options]
retry: 30
HTTP 200
[Captures]
admin_access_token: jsonpath "$.access_token"
Expand Down
30 changes: 25 additions & 5 deletions examples/st-oidc4vc/demo.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import json
import jwt
import requests
import threading
import time
import urllib

from cryptography.hazmat.primitives.asymmetric import ec


MOCKSERVER_URL = "http://localhost:5000"
LOGIN_REDIRECT_URL = "http://localhost:5000/cb"
Expand Down Expand Up @@ -57,7 +60,9 @@ def prepare_issuer():

# publish if not pending
if issuer_did["status"] == "CREATED":
requests.post(f"{AGENT_URL}/did-registrar/dids/{canonical_did}/publications")
requests.post(
f"{AGENT_URL}/did-registrar/dids/{canonical_did}/publications"
)

global CREDENTIAL_ISSUER
canonical_did = issuer_did["did"]
Expand Down Expand Up @@ -171,6 +176,24 @@ def holder_get_credential(credential_endpoint: str, token_response):
access_token = token_response["access_token"]
c_nonce = token_response["c_nonce"]
c_nonce_expires_in = token_response["c_nonce_expires_in"]

# generate proof
private_key = ec.generate_private_key(ec.SECP256K1())
jwt_proof = jwt.encode(
headers={
"typ": "openid4vci-proof+jwt",
"kid": "did:prism:0000000000000000000000000000000000000000000000000000000000000000#key-1", # TODO: use actual DID
},
payload={
"iss": ALICE_CLIENT_ID,
"aud": CREDENTIAL_ISSUER,
"iat": int(time.time()),
"nonce": c_nonce,
},
key=private_key,
algorithm="ES256K", # TODO: switch to EdDSA alg (Ed25519)
)

response = requests.post(
credential_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
Expand All @@ -180,10 +203,7 @@ def holder_get_credential(credential_endpoint: str, token_response):
"type": ["VerifiableCredential", CREDENTIAL_CONFIGURATION_ID],
"credentialSubject": {},
},
"proof": {
"proof_type": "jwt",
"jwt": f"jwt:{c_nonce}", # TODO: use actual JWT
},
"proof": {"proof_type": "jwt", "jwt": jwt_proof},
},
)
return response.json()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ object CredentialIssuerEndpoints {
.securityIn(apiKeyHeader)
.securityIn(jwtAuthHeader)

private val baseHolderFacingEndpoint = baseEndpoint
.securityIn(jwtAuthHeader)

val credentialEndpointErrorOutput = oneOf[Either[ErrorResponse, CredentialErrorResponse]](
oneOfVariantValueMatcher(StatusCode.BadRequest, jsonBody[Either[ErrorResponse, CredentialErrorResponse]]) {
case Right(CredentialErrorResponse(code, _, _, _)) if code.toHttpStatusCode == StatusCode.BadRequest => true
Expand All @@ -62,9 +59,10 @@ object CredentialIssuerEndpoints {
ExtendedErrorResponse,
CredentialResponse,
Any
] = baseHolderFacingEndpoint.post
] = baseEndpoint.post
.in("credentials")
.in(jsonBody[CredentialRequest])
.securityIn(jwtAuthHeader)
.out(
statusCode(StatusCode.Ok).description("Credential issued successfully"),
)
Expand Down Expand Up @@ -112,23 +110,19 @@ object CredentialIssuerEndpoints {
"""The endpoint that returns a `nonce` value for the [Token Endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-nonce-endpoint)""".stripMargin
)

val issuanceSessionEndpoint: Endpoint[
(ApiKeyCredentials, JwtCredentials),
(RequestContext, String, IssuanceSessionRequest),
ExtendedErrorResponse,
val issuerMetadataEndpoint: Endpoint[
Unit,
(RequestContext, String),
ErrorResponse,
IssuerMetadata,
Any
] =
baseIssuerFacingEndpoint.post
.in("issuance-session")
.in(jsonBody[IssuanceSessionRequest])
.out(
statusCode(StatusCode.Created).description("Issuance session created successfully"),
)
.errorOut(credentialEndpointErrorOutput)
.name("createIssuanceSession")
.summary("Create Issuance Session")
.description(
"""The endpoint that creates an issuance session for the OIDC VC endpoints""".stripMargin
)
] = baseEndpoint.get
.in(".well-known" / "openid-credential-issuer")
.out(
statusCode(StatusCode.Ok).description("Issuer Metadata successfully retrieved")
)
.out(jsonBody[IssuerMetadata])
.errorOut(EndpointOutputs.basicFailuresAndNotFound)
.name("getIssuerMetadata") // TODO: add endpoint documentation

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import io.iohk.atala.agent.walletapi.model.BaseEntity
import io.iohk.atala.api.http.{ErrorResponse, RequestContext}
import io.iohk.atala.iam.authentication.{Authenticator, Authorizer, DefaultAuthenticator, SecurityLogic}
import io.iohk.atala.iam.oidc.controller.CredentialIssuerController
import io.iohk.atala.iam.oidc.http.{CredentialErrorResponse, CredentialRequest, IssuanceSessionRequest, NonceResponse}
import io.iohk.atala.iam.oidc.http.{CredentialErrorResponse, CredentialRequest, NonceResponse}
import sttp.tapir.ztapir.*
import zio.*

Expand Down Expand Up @@ -57,25 +57,16 @@ case class CredentialIssuerServerEndpoints(
}
}

val issuanceSessionServerEndpoint: ZServerEndpoint[Any, Any] =
CredentialIssuerEndpoints.issuanceSessionEndpoint
.zServerSecurityLogic(
// FIXME: how can authorization server authorize itself?
SecurityLogic
.authorizeWalletAccessWith(_)(authenticator, authorizer)
.mapError(Left[ErrorResponse, CredentialErrorResponse])
)
.serverLogic { wac =>
{ case (ctx: RequestContext, didRef: String, issuanceSessionRequest: IssuanceSessionRequest) =>
credentialIssuerController.createIssuanceSession(ctx, didRef, issuanceSessionRequest)
}
}
val issuerMetadataEndpoint: ZServerEndpoint[Any, Any] = CredentialIssuerEndpoints.issuerMetadataEndpoint
.zServerLogic {
{ case (rc, didRef) => credentialIssuerController.getIssuerMetadata(rc, didRef).logTrace(rc) }
}

val all: List[ZServerEndpoint[Any, Any]] = List(
credentialServerEndpoint,
nonceServerEndpoint,
createCredentialOfferServerEndpoint,
issuanceSessionServerEndpoint
issuerMetadataEndpoint
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import io.iohk.atala.api.http.{ErrorResponse, RequestContext}
import io.iohk.atala.castor.core.model.did.{CanonicalPrismDID, PrismDID}
import io.iohk.atala.castor.core.service.DIDService
import io.iohk.atala.iam.oidc.CredentialIssuerEndpoints.ExtendedErrorResponse
import io.iohk.atala.iam.oidc.domain.IssuanceSession
import io.iohk.atala.iam.oidc.http.*
import io.iohk.atala.iam.oidc.http.CredentialErrorCode.*
import io.iohk.atala.iam.oidc.service.OIDCCredentialIssuerService
Expand All @@ -18,22 +17,23 @@ trait CredentialIssuerController {
didRef: String,
credentialRequest: CredentialRequest
): IO[ExtendedErrorResponse, CredentialResponse]

def createCredentialOffer(
ctx: RequestContext,
didRef: String,
credentialOfferRequest: CredentialOfferRequest
): ZIO[WalletAccessContext, ErrorResponse, CredentialOfferResponse]

def getNonce(
ctx: RequestContext,
didRef: String,
request: NonceRequest
): ZIO[WalletAccessContext, ErrorResponse, NonceResponse]

def createIssuanceSession(
def getIssuerMetadata(
ctx: RequestContext,
didRef: String,
issuanceSessionRequest: IssuanceSessionRequest
): IO[ExtendedErrorResponse, Unit]
didRef: String
): IO[ErrorResponse, IssuerMetadata]
}

object CredentialIssuerController {
Expand Down Expand Up @@ -82,24 +82,27 @@ object CredentialIssuerController {

case class CredentialIssuerControllerImpl(didService: DIDService, credentialIssuerService: OIDCCredentialIssuerService)
extends CredentialIssuerController {

import CredentialIssuerController.Errors.*
import OIDCCredentialIssuerService.Errors.*

def resolveIssuerDID(didRef: String): IO[ExtendedErrorResponse, CanonicalPrismDID] = {
private def parseIssuerDID[E](didRef: String, errorFn: (String, String) => E): IO[E, CanonicalPrismDID] = {
for {
prismDID: PrismDID <- ZIO
prismDID <- ZIO
.fromEither(PrismDID.fromString(didRef))
.mapError(didParsingError => badRequestInvalidDID(didRef, didParsingError))
// FIXME: do we need to resolve it if the DID document is not used?
// resolution <- didService
// .resolveDID(prismDID)
// .mapError(didResolutionError => badRequestInvalidDID(didRef, didResolutionError.message))
// canonicalDID <- ZIO
// .fromOption(resolution.map(_._2.id))
// .mapError(_ => badRequestDIDResolutionFailed(didRef, s"The DID $didRef is not resolvable"))
.mapError[E](didParsingError => errorFn(didRef, didParsingError))
} yield prismDID.asCanonical
}

private def parseIssuerDIDBasicError(didRef: String): IO[ErrorResponse, CanonicalPrismDID] =
parseIssuerDID(
didRef,
(didRef, detail) => ErrorResponse.badRequest(detail = Some(s"Invalid DID input $didRef. $detail"))
)

private def parseIssuerDIDOidc4vcError(difRef: String): IO[ExtendedErrorResponse, CanonicalPrismDID] =
parseIssuerDID(difRef, badRequestInvalidDID)

def issueCredential(
ctx: RequestContext,
didRef: String,
Expand Down Expand Up @@ -128,7 +131,7 @@ case class CredentialIssuerControllerImpl(didService: DIDService, credentialIssu
maybeProof match {
case Some(JwtProof(proofType, jwt)) =>
for {
canonicalPrismDID: CanonicalPrismDID <- resolveIssuerDID(didRef)
canonicalPrismDID: CanonicalPrismDID <- parseIssuerDIDOidc4vcError(didRef)
_ <- ZIO
.ifZIO(credentialIssuerService.verifyJwtProof(jwt))(
ZIO.unit,
Expand Down Expand Up @@ -157,74 +160,45 @@ case class CredentialIssuerControllerImpl(didService: DIDService, credentialIssu
}
}

// TODO: implement
override def createCredentialOffer(
ctx: RequestContext,
didRef: String,
credentialOfferRequest: CredentialOfferRequest
): ZIO[WalletAccessContext, ErrorResponse, CredentialOfferResponse] = {
for {
canonicalPrismDID <- ZIO
.fromEither(PrismDID.fromString(didRef))
.mapBoth(error => ErrorResponse.badRequest(detail = Some(s"Invalid DID input $didRef")), _.asCanonical)
canonicalPrismDID <- parseIssuerDIDBasicError(didRef)
resp <- credentialIssuerService
.createCredentialOffer(canonicalPrismDID, credentialOfferRequest.claims)
.map(offer => CredentialOfferResponse(offer.offerUri))
.mapError(ue =>
internalServerError(
"InternalServerError",
Some("TODO - handle error properly!!!"),
instance = "CredentialIssuerController"
)
internalServerError(detail = Some(s"Unexpected error while creating credential offer: ${ue.message}"))
)
} yield resp
}

// TODO: implement
override def getNonce(
ctx: RequestContext,
didRef: String,
request: NonceRequest
): ZIO[WalletAccessContext, ErrorResponse, NonceResponse] = {
credentialIssuerService
.getIssuanceSessionNonce(request.issuerState)
.map(nonce => NonceResponse(nonce.toString))
.map(nonce => NonceResponse(nonce))
.mapError(ue =>
internalServerError(
"InternalServerError",
Some("TODO - handle error properly!!!"),
instance = "CredentialIssuerController"
)
internalServerError(detail = Some(s"Unexpected error while creating credential offer: ${ue.message}"))
)
}

private def buildIssuanceSession(
canonicalPrismDID: CanonicalPrismDID,
issuanceSessionRequest: IssuanceSessionRequest
): IssuanceSession = {
IssuanceSession(
nonce = issuanceSessionRequest.nonce,
issuableCredentials = issuanceSessionRequest.issuableCredentials,
isPreAuthorized = issuanceSessionRequest.isPreAuthorized,
did = issuanceSessionRequest.did,
issuerDid = canonicalPrismDID,
userPin = issuanceSessionRequest.userPin
)
}

override def createIssuanceSession(
ctx: RequestContext,
didRef: String,
issuanceSessionRequest: IssuanceSessionRequest
): IO[ExtendedErrorResponse, Unit] = {
// TODO: implement
override def getIssuerMetadata(ctx: RequestContext, didRef: String): IO[ErrorResponse, IssuerMetadata] = {
for {
canonicalPrismDID <- resolveIssuerDID(didRef)
issuanceSession = buildIssuanceSession(canonicalPrismDID, issuanceSessionRequest)
_ <- credentialIssuerService
.createIssuanceSession(issuanceSession)
.mapError(ue => serverError(Some(s"Unexpected error while creating issuance session: ${ue.message}")))
} yield ()

canonicalPrismDID <- parseIssuerDIDBasicError(didRef)
credentialIssuerBaseUrl = s"http://localhost:8080/prism-agent/oidc4vc/${canonicalPrismDID.toString}"
} yield IssuerMetadata(
credential_issuer = credentialIssuerBaseUrl,
authorization_servers = Some(Seq("TODO: return url")),
credential_endpoint = s"$credentialIssuerBaseUrl/credentials",
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package io.iohk.atala.iam.oidc.domain

import io.iohk.atala.castor.core.model.did.CanonicalPrismDID
import io.iohk.atala.iam.oidc.http.IssuableCredential
import io.iohk.atala.castor.core.model.did.DID

import java.util.UUID

case class IssuanceSession(
id: UUID,
nonce: String,
issuableCredentials: Seq[IssuableCredential],
isPreAuthorized: Boolean,
did: Option[String],
issuerDid: CanonicalPrismDID,
userPin: Option[String]
issuerState: String,
schemaId: Option[String],
claims: zio.json.ast.Json,
subjectDid: Option[DID],
issuingDid: CanonicalPrismDID,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import sttp.tapir.json.zio.schemaForZioJsonValue
import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder}

case class CredentialOfferRequest(
schemaId: Option[String],
credentialConfigurationId: Option[String], // TODO: this field should be requried
claims: zio.json.ast.Json,
)

Expand Down

0 comments on commit 46c5966

Please sign in to comment.