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 Mar 22, 2024
1 parent fa8a653 commit 2acd76f
Show file tree
Hide file tree
Showing 13 changed files with 161 additions and 158 deletions.
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 @@ -3,7 +3,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
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
Loading

0 comments on commit 2acd76f

Please sign in to comment.