From a85b656bbbf4c1ef9037554fade8bacc0795fb88 Mon Sep 17 00:00:00 2001 From: Pradeep Kumar Prakasam Date: Tue, 10 Dec 2024 10:49:24 -0500 Subject: [PATCH] Multitenancy Support for OID4VC Plugin (#1214) * Refactor code to support multitenancy in OID4VC plugin Signed-off-by: Pradeep Kumar Prakasam * feat: add subpath to other routes Signed-off-by: Micah Peltier * feat: update demo to use multitentant wallet for testing Signed-off-by: Micah Peltier * Refactor test_public_route and linting error Signed-off-by: Pradeep Kumar Prakasam --------- Signed-off-by: Pradeep Kumar Prakasam Signed-off-by: Micah Peltier Co-authored-by: Micah Peltier --- oid4vc/demo/docker-compose.yaml | 7 +++ oid4vc/demo/frontend/index.js | 36 +++++++++++ oid4vc/oid4vc/oid4vci_server.py | 58 ++++++++++++++---- oid4vc/oid4vc/public_routes.py | 60 +++++++++++++------ oid4vc/oid4vc/routes.py | 16 ++++- .../oid4vc/tests/routes/test_public_routes.py | 4 +- 6 files changed, 146 insertions(+), 35 deletions(-) diff --git a/oid4vc/demo/docker-compose.yaml b/oid4vc/demo/docker-compose.yaml index 9dab91161..f8ad4f75e 100644 --- a/oid4vc/demo/docker-compose.yaml +++ b/oid4vc/demo/docker-compose.yaml @@ -43,6 +43,10 @@ services: --plugin oid4vc --plugin sd_jwt_vc --plugin mso_mdoc + --multitenant + --multitenant-admin + --jwt-secret insecure + --multitenancy-config wallet_type=single-wallet-askar key_derivation_method=RAW healthcheck: test: curl -s -o /dev/null -w '%{http_code}' "http://localhost:3001/status/live" | grep "200" > /dev/null start_period: 30s @@ -89,3 +93,6 @@ services: - WDS_SOCKET_PORT=0 - API_BASE_URL=http://issuer:3001 - FORCE_COLOR=3 + depends_on: + issuer: + condition: service_healthy diff --git a/oid4vc/demo/frontend/index.js b/oid4vc/demo/frontend/index.js index 4b17b5d65..9a467d321 100644 --- a/oid4vc/demo/frontend/index.js +++ b/oid4vc/demo/frontend/index.js @@ -95,6 +95,7 @@ async function issue_jwt_credential(req, res) { const commonHeaders = { accept: "application/json", "Content-Type": "application/json", + "Authorization": "Bearer " + token.token, }; if (API_KEY) { commonHeaders["X-API-KEY"] = API_KEY; @@ -102,6 +103,8 @@ async function issue_jwt_credential(req, res) { axios.defaults.withCredentials = true; axios.defaults.headers.common["Access-Control-Allow-Origin"] = API_BASE_URL; axios.defaults.headers.common["X-API-KEY"] = API_KEY; + axios.defaults.headers.common["Authorization"] = "Bearer " + token.token; + const fetchApiData = async (url, options) => { const response = await fetch(url, options); @@ -253,6 +256,7 @@ async function issue_sdjwt_credential(req, res) { const commonHeaders = { accept: "application/json", "Content-Type": "application/json", + "Authorization": "Bearer " + token.token, }; if (API_KEY) { commonHeaders["X-API-KEY"] = API_KEY; @@ -260,6 +264,7 @@ async function issue_sdjwt_credential(req, res) { axios.defaults.withCredentials = true; axios.defaults.headers.common["Access-Control-Allow-Origin"] = API_BASE_URL; axios.defaults.headers.common["X-API-KEY"] = API_KEY; + axios.defaults.headers.common["Authorization"] = "Bearer " + token.token; const fetchApiData = async (url, options) => { const response = await fetch(url, options); @@ -442,6 +447,7 @@ async function create_jwt_vc_presentation(req, res) { const commonHeaders = { accept: "application/json", "Content-Type": "application/json", + "Authorization": "Bearer " + token.token, }; if (API_KEY) { commonHeaders["X-API-KEY"] = API_KEY; @@ -449,6 +455,8 @@ async function create_jwt_vc_presentation(req, res) { axios.defaults.withCredentials = true; axios.defaults.headers.common["Access-Control-Allow-Origin"] = API_BASE_URL; axios.defaults.headers.common["X-API-KEY"] = API_KEY; + axios.defaults.headers.common["Authorization"] = "Bearer " + token.token; + const fetchApiData = async (url, options) => { const response = await fetch(url, options); @@ -595,6 +603,7 @@ async function create_sd_jwt_presentation(req, res) { const commonHeaders = { accept: "application/json", "Content-Type": "application/json", + "Authorization": "Bearer " + token.token, }; if (API_KEY) { commonHeaders["X-API-KEY"] = API_KEY; @@ -602,6 +611,8 @@ async function create_sd_jwt_presentation(req, res) { axios.defaults.withCredentials = true; axios.defaults.headers.common["Access-Control-Allow-Origin"] = API_BASE_URL; axios.defaults.headers.common["X-API-KEY"] = API_KEY; + axios.defaults.headers.common["Authorization"] = "Bearer " + token.token; + const fetchApiData = async (url, options) => { const response = await fetch(url, options); @@ -838,6 +849,31 @@ app.get("/", (req, res) => { res.render("index", {"registrationId": uuidv4()}); }); +const fetchApiData = async (url, options) => { + const response = await fetch(url, options); + return await response.json(); +}; + +const token = await fetchApiData( + `${API_BASE_URL}/multitenancy/wallet`, + { + method: "POST", + headers: { + accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify( + { + "label": "Alice", + "wallet_type": "askar", + } + ) + } +); + +console.log("_______TOKEN________\n\n\n"); +console.log(token); + // Render Credential Issuance form app.get("/issue", (req, res) => { res.render("issue-form", {"page": "register", "registrationId": uuidv4()}); diff --git a/oid4vc/oid4vc/oid4vci_server.py b/oid4vc/oid4vc/oid4vci_server.py index 6e8dc6867..a2aed5c99 100644 --- a/oid4vc/oid4vc/oid4vci_server.py +++ b/oid4vc/oid4vc/oid4vci_server.py @@ -9,6 +9,10 @@ from acapy_agent.admin.server import debug_middleware, ready_middleware from acapy_agent.config.injection_context import InjectionContext from acapy_agent.core.profile import Profile +from acapy_agent.wallet.models.wallet_record import WalletRecord +from acapy_agent.storage.error import StorageError +from acapy_agent.messaging.models.base import BaseModelError +from acapy_agent.multitenant.base import BaseMultitenantManager from aiohttp import web from aiohttp_apispec import setup_aiohttp_apispec, validation_middleware @@ -51,6 +55,7 @@ def __init__( self.context = context self.profile = root_profile self.site = None + self.multitenant_manager = context.inject_or(BaseMultitenantManager) async def make_application(self) -> web.Application: """Get the aiohttp application instance.""" @@ -61,18 +66,47 @@ async def make_application(self) -> web.Application: async def setup_context(request: web.Request, handler): """Set up request context. - TODO: support Multitenancy context setup - Right now, this will only work for a standard agent instance. To - support multitenancy, we will need to include wallet identifiers in - the path and report that path in credential offers and issuer - metadata from a tenant. + This middleware is responsible for setting up the request context for the + handler. If multitenancy is enabled and a wallet_id is provided in the request + the wallet profile is retrieved and injected into the context. + + Args: + request (web.Request): The incoming web request. + handler: The handler function to be executed. + + Returns: + The result of executing the handler function with the updated request + context. """ - admin_context = AdminRequestContext( - profile=self.profile, - # root_profile=self.profile, # TODO: support Multitenancy context setup - # metadata={}, # TODO: support Multitenancy context setup - ) - request["context"] = admin_context + multitenant = self.multitenant_manager + wallet_id = request.match_info.get("wallet_id") + + if multitenant and wallet_id: + try: + async with self.profile.session() as session: + wallet_record = await WalletRecord.retrieve_by_id( + session, wallet_id + ) + except (StorageError, BaseModelError) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + wallet_info = wallet_record.serialize() + wallet_key = wallet_info["settings"]["wallet.key"] + _, wallet_profile = await multitenant.get_wallet_and_profile( + self.context, wallet_id, wallet_key + ) + admin_context = AdminRequestContext( + profile=wallet_profile, + root_profile=self.profile, + metadata={ + "wallet_id": wallet_id, + "wallet_key": wallet_key, + }, + ) + request["context"] = admin_context + else: + request["context"] = AdminRequestContext( + profile=self.profile, + ) return await handler(request) middlewares.append(setup_context) @@ -94,7 +128,7 @@ async def setup_context(request: web.Request, handler): ] ) - await public_routes_register(app) + await public_routes_register(app, self.multitenant_manager) cors = aiohttp_cors.setup( app, diff --git a/oid4vc/oid4vc/public_routes.py b/oid4vc/oid4vc/public_routes.py index b1d56855c..92e3b3c5f 100644 --- a/oid4vc/oid4vc/public_routes.py +++ b/oid4vc/oid4vc/public_routes.py @@ -71,7 +71,9 @@ class CredentialIssuerMetadataSchema(OpenAPISchema): ) authorization_server = fields.Str( required=False, - metadata={"description": "The authorization server endpoint. Currently ignored."}, + metadata={ + "description": "The authorization server endpoint. Currently ignored." + }, ) batch_credential_endpoint = fields.Str( required=False, @@ -91,13 +93,15 @@ async def credential_issuer_metadata(request: web.Request): # TODO If there's a lot, this will be a problem credentials_supported = await SupportedCredential.query(session) - metadata = { - "credential_issuer": f"{public_url}/", - "credential_endpoint": f"{public_url}/credential", - "credentials_supported": [ - supported.to_issuer_metadata() for supported in credentials_supported - ], - } + wallet_id = request.match_info.get("wallet_id") + subpath = f"/tenant/{wallet_id}" if wallet_id else "" + metadata = { + "credential_issuer": f"{public_url}{subpath}", + "credential_endpoint": f"{public_url}{subpath}/credential", + "credentials_supported": [ + supported.to_issuer_metadata() for supported in credentials_supported + ], + } LOGGER.debug("METADATA: %s", metadata) @@ -203,7 +207,9 @@ async def check_token( return result -async def handle_proof_of_posession(profile: Profile, proof: Dict[str, Any], nonce: str): +async def handle_proof_of_posession( + profile: Profile, proof: Dict[str, Any], nonce: str +): """Handle proof of posession.""" encoded_headers, encoded_payload, encoded_signature = proof["jwt"].split(".", 3) headers = b64_to_dict(encoded_headers) @@ -309,7 +315,9 @@ async def issue_cred(request: web.Request): if "proof" not in body: raise web.HTTPBadRequest(reason=f"proof is required for {supported.format}") - pop = await handle_proof_of_posession(context.profile, body["proof"], ex_record.nonce) + pop = await handle_proof_of_posession( + context.profile, body["proof"], ex_record.nonce + ) if not pop.verified: raise web.HTTPBadRequest(reason="Invalid proof") @@ -447,6 +455,12 @@ async def get_request(request: web.Request): now = int(time.time()) config = Config.from_settings(context.settings) + wallet_id = ( + context.profile.settings.get("wallet.id") + if context.profile.settings.get("multitenant.enabled") + else None + ) + subpath = f"/tenant/{wallet_id}" if wallet_id else "" payload = { "iss": jwk.did, "sub": jwk.did, @@ -455,7 +469,9 @@ async def get_request(request: web.Request): "exp": now + 120, "jti": str(uuid.uuid4()), "client_id": config.endpoint, - "response_uri": f"{config.endpoint}/oid4vp/response/{pres.presentation_id}", + "response_uri": ( + f"{config.endpoint}{subpath}/oid4vp/response/{pres.presentation_id}" + ), "state": pres.presentation_id, "nonce": pres.nonce, "id_token_signing_alg_values_supported": ["ES256", "EdDSA"], @@ -529,7 +545,9 @@ async def verify_presentation( processors = profile.inject(CredProcessors) if not submission.descriptor_maps: - raise web.HTTPBadRequest(reason="Descriptor map of submission must not be empty") + raise web.HTTPBadRequest( + reason="Descriptor map of submission must not be empty" + ) # TODO: Support longer descriptor map arrays if len(submission.descriptor_maps) != 1: @@ -618,20 +636,24 @@ async def post_response(request: web.Request): return web.Response(status=200) -async def register(app: web.Application): - """Register routes.""" +async def register(app: web.Application, multitenant: bool): + """Register routes with support for multitenant mode. + + Adds the subpath with Wallet ID as a path parameter if multitenant is True. + """ + subpath = "/tenant/{wallet_id}" if multitenant else "" app.add_routes( [ web.get( - "/.well-known/openid-credential-issuer", + f"{subpath}/.well-known/openid-credential-issuer", credential_issuer_metadata, allow_head=False, ), # TODO Add .well-known/did-configuration.json # Spec: https://identity.foundation/.well-known/resources/did-configuration/ - web.post("/token", token), - web.post("/credential", issue_cred), - web.get("/oid4vp/request/{request_id}", get_request), - web.post("/oid4vp/response/{presentation_id}", post_response), + web.post(f"{subpath}/token", token), + web.post(f"{subpath}/credential", issue_cred), + web.get(f"{subpath}/oid4vp/request/{{request_id}}", get_request), + web.post(f"{subpath}/oid4vp/response/{{presentation_id}}", post_response), ] ) diff --git a/oid4vc/oid4vc/routes.py b/oid4vc/oid4vc/routes.py index 0d8ddbb50..34e415810 100644 --- a/oid4vc/oid4vc/routes.py +++ b/oid4vc/oid4vc/routes.py @@ -362,8 +362,14 @@ async def get_cred_offer(request: web.BaseRequest): raise web.HTTPBadRequest(reason=err.roll_up) from err user_pin_required: bool = record.pin is not None + wallet_id = ( + context.profile.settings.get("wallet.id") + if context.profile.settings.get("multitenant.enabled") + else None + ) + subpath = f"/tenant/{wallet_id}" if wallet_id else "" offer = { - "credential_issuer": config.endpoint, + "credential_issuer": f"{config.endpoint}{subpath}", "credentials": [supported.identifier], "grants": { "urn:ietf:params:oauth:grant-type:pre-authorized_code": { @@ -782,7 +788,13 @@ async def create_oid4vp_request(request: web.Request): await pres_record.save(session=session) config = Config.from_settings(context.settings) - request_uri = quote(f"{config.endpoint}/oid4vp/request/{req_record._id}") + wallet_id = ( + context.profile.settings.get("wallet.id") + if context.profile.settings.get("multitenant.enabled") + else None + ) + subpath = f"/tenant/{wallet_id}" if wallet_id else "" + request_uri = quote(f"{config.endpoint}{subpath}/oid4vp/request/{req_record._id}") full_uri = f"openid://?request_uri={request_uri}" return web.json_response( diff --git a/oid4vc/oid4vc/tests/routes/test_public_routes.py b/oid4vc/oid4vc/tests/routes/test_public_routes.py index 05b213eb8..3ad009463 100644 --- a/oid4vc/oid4vc/tests/routes/test_public_routes.py +++ b/oid4vc/oid4vc/tests/routes/test_public_routes.py @@ -26,8 +26,8 @@ async def test_issuer_metadata(context: AdminRequestContext, req: web.Request): await test_module.credential_issuer_metadata(req) mock_web.json_response.assert_called_once_with( { - "credential_issuer": "http://localhost:8020/", - "credential_endpoint": "http://localhost:8020/credential", + "credential_issuer": f"http://localhost:8020/tenant/{req.match_info.get()}", + "credential_endpoint": f"http://localhost:8020/tenant/{req.match_info.get()}/credential", "credentials_supported": [ { "format": "jwt_vc_json",