Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multitenancy Support for OID4VC Plugin #1214

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions oid4vc/demo/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,3 +93,6 @@ services:
- WDS_SOCKET_PORT=0
- API_BASE_URL=http://issuer:3001
- FORCE_COLOR=3
depends_on:
issuer:
condition: service_healthy
36 changes: 36 additions & 0 deletions oid4vc/demo/frontend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,16 @@ 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;
}
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);
Expand Down Expand Up @@ -253,13 +256,15 @@ 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;
}
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);
Expand Down Expand Up @@ -442,13 +447,16 @@ 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;
}
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);
Expand Down Expand Up @@ -595,13 +603,16 @@ 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;
}
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);
Expand Down Expand Up @@ -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()});
Expand Down
58 changes: 46 additions & 12 deletions oid4vc/oid4vc/oid4vci_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand All @@ -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)
Expand All @@ -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,
Expand Down
60 changes: 41 additions & 19 deletions oid4vc/oid4vc/public_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand All @@ -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"],
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
]
)
16 changes: 14 additions & 2 deletions oid4vc/oid4vc/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions oid4vc/oid4vc/tests/routes/test_public_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading